Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions client/src/hooks/useEditEntryParam.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import { useNotification } from "../hooks/useNotification";
import { EDIT_PARAM } from "../utils/activityLinks";

/**
* Open an entry's edit form when the page is opened with `?edit=<id>`.
*
* Tapping an entry in the dashboard or the activity feed navigates to the
* section page that owns it, so the user lands in the right context with the
* edit dialog already open — closing it leaves them on the full list.
*
* The entry is fetched by id rather than looked up in the page's own list: the
* activity feed pages back through the entire history, so the tapped entry is
* often older than the entries a section page loads up front.
*
* @param resource API resource segment, e.g. `diaper-changes`
* @param onEdit the page's existing edit handler
*/
export function useEditEntryParam<T>(
resource: string,
onEdit: (entry: T) => void,
): void {
const [searchParams, setSearchParams] = useSearchParams();
const { notify } = useNotification();
const editId = searchParams.get(EDIT_PARAM);

// Keep the latest handler without re-running the effect: pages redefine it on
// every render, and re-running would reopen the dialog over the user's edits.
const onEditRef = useRef(onEdit);
onEditRef.current = onEdit;

const handledId = useRef<string | null>(null);

// Tracks the component, not the effect: dropping the `edit` param below
// re-runs the effect, and tying the in-flight fetch to that run would cancel
// the very request that opens the form.
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);

useEffect(() => {
if (!editId || handledId.current === editId) return;
handledId.current = editId;

// Drop the param up front so a refresh — or coming back to this page — does
// not reopen the form. `replace` keeps the back button pointing at the view
// the user tapped from.
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete(EDIT_PARAM);
return next;
},
{ replace: true },
);

(async () => {
try {
const entry = await api.get<T>(`/${resource}/${editId}`);
if (mounted.current) onEditRef.current(entry);
} catch (err) {
if (mounted.current) {
notify(
err instanceof Error ? err.message : "Failed to open entry.",
"error",
);
}
}
})();
}, [editId, resource, setSearchParams, notify]);
}
40 changes: 38 additions & 2 deletions client/src/pages/ActivityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
Expand All @@ -25,12 +26,14 @@ import {
type CategoryKey,
type CategoryColorSet,
} from "../theme/categoryColors";
import { editEntryPath } from "../utils/activityLinks";

/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */

interface ActivityEntry {
id: number;
activity_type: string;
event_time: string;
detail: string;
Expand Down Expand Up @@ -258,6 +261,7 @@ export default function ActivityPage() {
const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const { notify } = useNotification();
const navigate = useNavigate();

const [entries, setEntries] = useState<ActivityEntry[]>([]);
const [total, setTotal] = useState(0);
Expand Down Expand Up @@ -707,9 +711,10 @@ export default function ActivityPage() {
<Stack sx={{ gap: 1.5 }}>
{items.map((entry, i) => {
const cc = catColors(entry.activity_type);
const editPath = editEntryPath(cc.key, entry.id);
return (
<Box
key={`${entry.activity_type}-${entry.event_time}-${i}`}
key={`${entry.activity_type}-${entry.id}-${i}`}
sx={{ position: "relative" }}
>
{/* Timeline dot */}
Expand Down Expand Up @@ -738,9 +743,24 @@ export default function ActivityPage() {
/>
</Box>

{/* Event card */}
{/* Event card — tapping opens the entry's edit form on
the section page that owns it. */}
<Box
component={editPath ? "button" : "div"}
type={editPath ? "button" : undefined}
onClick={
editPath ? () => navigate(editPath) : undefined
}
aria-label={
editPath
? `Edit ${entry.activity_type} at ${formatTime(entry.event_time)}`
: undefined
}
sx={{
width: "100%",
font: "inherit",
color: "inherit",
textAlign: "left",
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
Expand All @@ -751,6 +771,11 @@ export default function ActivityPage() {
display: "flex",
alignItems: "center",
gap: 1.5,
...(editPath && {
cursor: "pointer",
transition: "border-color 0.15s ease",
"&:hover": { borderColor: cc.edge },
}),
}}
>
{/* Icon */}
Expand Down Expand Up @@ -796,6 +821,17 @@ export default function ActivityPage() {
>
{formatTime(entry.event_time)}
</Typography>

{editPath && (
<ChevronRightIcon
sx={{
fontSize: 16,
color: "text.disabled",
flexShrink: 0,
ml: -0.75,
}}
/>
)}
</Box>
</Box>
);
Expand Down
29 changes: 22 additions & 7 deletions client/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useNotification } from "../hooks/useNotification";
import NoChildPlaceholder from "../components/NoChildPlaceholder";
import QuickLogDialog, { type QuickLogCategory } from "../components/QuickLogDialog";
import { buildCategoryColors, type CategoryKey } from "../theme/categoryColors";
import { editEntryPath } from "../utils/activityLinks";
import { sideLabel } from "../utils/pumping";
import type {
Feeding,
Expand Down Expand Up @@ -286,14 +287,14 @@ export default function Dashboard() {
{ cat: "pump", value: todayPumpOz > 0 ? `${todayPumpOz} oz` : `${todayPumpCount}`, label: "pumped", sub: todayPumpCount > 0 ? `${todayPumpCount} sessions` : "today" },
];

const recentActivity: { cat: CategoryKey; title: string; time: string; meta: string; live?: boolean }[] = [];
const recentActivity: { id: number; cat: CategoryKey; title: string; time: string; meta: string; live?: boolean }[] = [];
const cutoff = 6;
const allEvents: typeof recentActivity = [];
feedings.slice(0, 10).forEach((f) => allEvents.push({ cat: "feed", title: `${prettifyType(f.type)}${f.amount ? ` · ${f.amount}${f.amount_unit ? ` ${f.amount_unit}` : ""}` : ""}`, time: new Date(f.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(f.start_time, f.end_time) }));
diapers.slice(0, 10).forEach((d) => allEvents.push({ cat: "diaper", title: `Diaper · ${prettifyType(d.type)}`, time: new Date(d.time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: d.color || "" }));
sleeps.slice(0, 10).forEach((s) => allEvents.push({ cat: "sleep", title: s.is_nap ? "Nap" : "Sleep", time: new Date(s.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: s.end_time ? formatDuration(s.start_time, s.end_time) : `Active · ${formatDuration(s.start_time, null)}`, live: !s.end_time }));
pumpings.slice(0, 10).forEach((p) => allEvents.push({ cat: "pump", title: `Pump${sideLabel(p.side) ? ` · ${sideLabel(p.side)}` : ""}${p.amount ? ` · ${p.amount}${p.amount_unit ? ` ${p.amount_unit}` : ""}` : ""}`, time: new Date(p.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(p.start_time, p.end_time) }));
tummyTimes.slice(0, 10).forEach((tt) => allEvents.push({ cat: "tummy", title: "Tummy time", time: new Date(tt.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(tt.start_time, tt.end_time) }));
feedings.slice(0, 10).forEach((f) => allEvents.push({ id: f.id, cat: "feed", title: `${prettifyType(f.type)}${f.amount ? ` · ${f.amount}${f.amount_unit ? ` ${f.amount_unit}` : ""}` : ""}`, time: new Date(f.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(f.start_time, f.end_time) }));
diapers.slice(0, 10).forEach((d) => allEvents.push({ id: d.id, cat: "diaper", title: `Diaper · ${prettifyType(d.type)}`, time: new Date(d.time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: d.color || "" }));
sleeps.slice(0, 10).forEach((s) => allEvents.push({ id: s.id, cat: "sleep", title: s.is_nap ? "Nap" : "Sleep", time: new Date(s.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: s.end_time ? formatDuration(s.start_time, s.end_time) : `Active · ${formatDuration(s.start_time, null)}`, live: !s.end_time }));
pumpings.slice(0, 10).forEach((p) => allEvents.push({ id: p.id, cat: "pump", title: `Pump${sideLabel(p.side) ? ` · ${sideLabel(p.side)}` : ""}${p.amount ? ` · ${p.amount}${p.amount_unit ? ` ${p.amount_unit}` : ""}` : ""}`, time: new Date(p.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(p.start_time, p.end_time) }));
tummyTimes.slice(0, 10).forEach((tt) => allEvents.push({ id: tt.id, cat: "tummy", title: "Tummy time", time: new Date(tt.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(tt.start_time, tt.end_time) }));

allEvents.sort((a, b) => {
const ta = new Date(`1970-01-01 ${a.time}`).getTime();
Expand Down Expand Up @@ -561,14 +562,25 @@ export default function Dashboard() {
) : (
recentActivity.map((ev, i) => {
const c = cat[ev.cat];
const editPath = editEntryPath(ev.cat, ev.id);
return (
// Tapping a row opens that entry's edit form on its section page.
<Box
key={i}
key={`${ev.cat}-${ev.id}`}
component={editPath ? "button" : "div"}
type={editPath ? "button" : undefined}
onClick={editPath ? () => navigate(editPath) : undefined}
aria-label={editPath ? `Edit ${ev.title} at ${ev.time}` : undefined}
sx={{
display: "flex", alignItems: "center", gap: 1,
py: "6px",
width: "100%",
font: "inherit", color: "inherit", textAlign: "left",
bgcolor: "transparent",
border: 0,
borderBottom: i === recentActivity.length - 1 ? "none" : 1,
borderColor: "divider",
...(editPath && { cursor: "pointer" }),
}}
>
<Box
Expand Down Expand Up @@ -599,6 +611,9 @@ export default function Dashboard() {
>
{ev.time}
</Typography>
{editPath && (
<ChevronRightIcon sx={{ fontSize: 13, color: "text.disabled", flexShrink: 0 }} />
)}
</Box>
);
})
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/DiapersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";
import type { DiaperChange } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { buildCategoryColors } from "../theme/categoryColors";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

const KNOWN_COLOR_SWATCHES: Record<string, string> = {
yellow: "#f9d71c",
Expand Down Expand Up @@ -137,6 +138,10 @@ export default function DiapersPage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<DiaperChange>("diaper-changes", handleEdit);

const handleAdd = () => {
setEditingEntry(null);
setForm({ time: "", type: "wet", color: "", notes: "" });
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/FeedingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";
import type { Feeding } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { buildCategoryColors } from "../theme/categoryColors";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

const FEEDING_TYPES = [
{ value: "breast_left", label: "Breast (Left)" },
Expand Down Expand Up @@ -171,6 +172,10 @@ export default function FeedingsPage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<Feeding>("feedings", handleEdit);

const handleSave = async () => {
if (!selectedChild) return;
const trackAmount = !isBreastFeeding(form.type) && form.amount;
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/MedicationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";
import type { Medication } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { buildCategoryColors } from "../theme/categoryColors";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
Expand Down Expand Up @@ -141,6 +142,10 @@ export default function MedicationsPage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<Medication>("medications", handleEdit);

const handleSave = async () => {
if (!selectedChild) return;
const payload = {
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/NotesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";
import { buildCategoryColors } from "../theme/categoryColors";
import type { Note } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

function relativeTime(iso: string): string {
const now = new Date();
Expand Down Expand Up @@ -137,6 +138,10 @@ export default function NotesPage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<Note>("notes", handleEdit);

const handleSave = async () => {
if (!selectedChild) return;
const payload = {
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/PumpingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type { Pumping } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { PUMPING_SIDES, sideLabel } from "../utils/pumping";
import { buildCategoryColors } from "../theme/categoryColors";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
Expand Down Expand Up @@ -144,6 +145,10 @@ export default function PumpingPage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<Pumping>("pumping", handleEdit);

const openAdd = () => {
setEditingEntry(null);
setForm({ start_time: "", end_time: "", side: "both", amount: "", amount_unit: "oz", notes: "" });
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/SleepPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";
import type { SleepEntry } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { buildCategoryColors } from "../theme/categoryColors";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

function humanDuration(ms: number): string {
if (ms < 0) ms = 0;
Expand Down Expand Up @@ -173,6 +174,10 @@ export default function SleepPage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<SleepEntry>("sleep", handleEdit);

const closeDialog = () => {
setDialogOpen(false);
setEditingEntry(null);
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/TemperaturePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type { Temperature } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { buildCategoryColors } from "../theme/categoryColors";
import type { Chip as _Chip } from "@mui/material";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

type FeverLevel = "normal" | "lowFever" | "highFever";

Expand Down Expand Up @@ -149,6 +150,10 @@ export default function TemperaturePage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<Temperature>("temperature", handleEdit);

const handleSave = async () => {
if (!selectedChild) return;
const payload = {
Expand Down
5 changes: 5 additions & 0 deletions client/src/pages/TummyTimePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";
import type { TummyTime } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { buildCategoryColors } from "../theme/categoryColors";
import { useEditEntryParam } from "../hooks/useEditEntryParam";

function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
Expand Down Expand Up @@ -164,6 +165,10 @@ export default function TummyTimePage() {
setDialogOpen(true);
};

// Opening this page as `?edit=<id>` (from the dashboard or the activity
// feed) drops straight into that entry's edit form.
useEditEntryParam<TummyTime>("tummy-time", handleEdit);

const handleSave = async () => {
if (!selectedChild) return;
const payload = {
Expand Down
Loading