diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 7f5cfcec8..a46764990 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -48,15 +48,17 @@ import { Trash2, ChevronLeft, ChevronRight, + ChevronDown, ArrowUp, ArrowDown, ArrowUpDown, MoreHorizontal, } from "lucide-react"; -import { useId, useState } from "react"; +import { Fragment, useId, useState } from "react"; import { createPortal } from "react-dom"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; import { DateTimeInput } from "@/components/ui/date-input"; import { @@ -65,6 +67,15 @@ import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS, } from "./measurement-list-meta"; +/** + * v1.4.37 W7c — cumulative HK types whose list view collapses to one + * row per user-TZ day with an expand-chevron drill-down to the + * per-sample chunks. Mirrors the server-side + * `CUMULATIVE_DAY_SUM_TYPES` so the route's `groupBy=day` branch and + * the client's filter-detection agree on the same five identifiers. + */ +const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); + interface Measurement { id: string; type: string; @@ -73,6 +84,13 @@ interface Measurement { source: string; measuredAt: string; notes: string | null; + /** + * v1.4.37 W7c — present only on collapsed daily rows returned by + * `?type=…&groupBy=day`. Drives the expand chevron + the drill-down + * fetch keyed by the user's calendar day. + */ + dayKey?: string; + sampleCount?: number; } interface MeasurementListProps { @@ -140,6 +158,21 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { const [sortBy, setSortBy] = useState("measuredAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // v1.4.37 W7c — set of dayKeys whose drill-down is currently + // expanded. Each key maps to a one-shot query that lazy-fetches + // the per-sample rows for that calendar day on first open. + const [expandedDayKeys, setExpandedDayKeys] = useState>( + () => new Set(), + ); + function toggleExpand(dayKey: string) { + setExpandedDayKeys((prev) => { + const next = new Set(prev); + if (next.has(dayKey)) next.delete(dayKey); + else next.add(dayKey); + return next; + }); + } + const [editing, setEditing] = useState(null); const [editValue, setEditValue] = useState(""); const [editMeasuredAt, setEditMeasuredAt] = useState(""); @@ -172,6 +205,16 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { setPage(1); } + // v1.4.37 W7c — when the type filter is a cumulative HK type + // (steps, active energy, distance, flights, daylight) the list + // collapses to one row per user-TZ day via the route's + // `groupBy=day` mode. The chevron in the row drills back into the + // per-sample chunks via a separate `dayKey=…` query. Matches the + // Apple Health.app / Garmin / Withings pattern documented in + // `.planning/research/v1437-step-aggregation.md` §"How the leaders + // do it" — never list per-sample chunks as the default view. + const isCumulativeFilter = CUMULATIVE_TYPES.has(typeFilter); + const { data, isLoading } = useQuery({ queryKey: [ "measurements", @@ -179,6 +222,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { page, sortBy, sortDir, + isCumulativeFilter ? "groupBy=day" : "raw", ], queryFn: async () => { const params = new URLSearchParams(); @@ -187,6 +231,14 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { params.set("offset", String((page - 1) * PAGE_SIZE)); params.set("sortBy", sortBy); params.set("sortDir", sortDir); + if (isCumulativeFilter) { + params.set("groupBy", "day"); + // The collapsed mode synthesises one row per day from the + // window of raw samples — bump the per-page row scan so a + // wide history can render a full page of daily rows without + // missing back-fill from a chatty Apple-Watch day. + params.set("limit", String(Math.max(PAGE_SIZE * 50, 1000))); + } const res = await fetch(`/api/measurements?${params}`); if (!res.ok) throw new Error("Failed to fetch"); const json = await res.json(); @@ -450,62 +502,118 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { - {data.measurements.map((m) => ( - - - - {TYPE_LABEL_KEYS[m.type] - ? t(TYPE_LABEL_KEYS[m.type]) - : m.type} - - - - {m.value} {m.unit} - - - {formatDateTime(m.measuredAt)} - - - {m.notes ? ( - - {truncateComment(m.notes)} - - ) : ( - - + {data.measurements.map((m) => { + const isGrouped = + m.dayKey !== undefined && m.sampleCount !== undefined; + const isExpanded = isGrouped + ? expandedDayKeys.has(m.dayKey as string) + : false; + return ( + + + + + {TYPE_LABEL_KEYS[m.type] + ? t(TYPE_LABEL_KEYS[m.type]) + : m.type} + + + + {fmt.integer(m.value)} {m.unit} + {isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer( + m.sampleCount as number, + ), + })} + + )} + + + {formatDateTime(m.measuredAt)} + + + {m.notes ? ( + + {truncateComment(m.notes)} + + ) : ( + - + )} + + + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )} + + +
+ {isGrouped ? ( + + ) : ( + <> + + + deleteMutation.mutate(m.id) + } + /> + + )} +
+
+
+ {isGrouped && isExpanded && ( + + + + + )} -
- - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} - - -
- - deleteMutation.mutate(m.id)} - /> -
-
-
- ))} + + ); + })}
@@ -514,75 +622,116 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
{data.measurements.map((m) => { const Icon = TYPE_ICONS[m.type]; + const isGrouped = + m.dayKey !== undefined && m.sampleCount !== undefined; + const isExpanded = isGrouped + ? expandedDayKeys.has(m.dayKey as string) + : false; return (
-
- {Icon && ( -
- -
- )} -
- {/* v1.4.27 MB7 / CF-76 — bump the metadata - badges from `text-[10px]` to `text-[11px]` - so the legibility floor (12 px is the - mobile baseline; 11 px is the lowest - tolerated value for non-primary chrome) - holds across the row. The badge heights - stay at `h-5` / `h-4` since the type- - preface badge owns vertical real estate - inside the headline line. */} - {(m.type === "BLOOD_PRESSURE_SYS" || - m.type === "BLOOD_PRESSURE_DIA") && ( - +
+ {Icon && ( +
- {t(TYPE_LABEL_KEYS[m.type])} - + +
)} - - {m.value} {m.unit} - -

- {formatDateTime(m.measuredAt)} - {m.source !== "MANUAL" && ( +

+ {/* v1.4.27 MB7 / CF-76 — bump the metadata + badges from `text-[10px]` to `text-[11px]` + so the legibility floor (12 px is the + mobile baseline; 11 px is the lowest + tolerated value for non-primary chrome) + holds across the row. */} + {(m.type === "BLOOD_PRESSURE_SYS" || + m.type === "BLOOD_PRESSURE_DIA") && ( - {formatMeasurementSource(m.source, t)} + {t(TYPE_LABEL_KEYS[m.type])} )} -

- {m.notes && ( + + {fmt.integer(m.value)} {m.unit} + + {isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + )}

- {truncateComment(m.notes)} + {formatDateTime(m.measuredAt)} + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )}

+ {m.notes && ( +

+ {truncateComment(m.notes)} +

+ )} +
+
+
+ {isGrouped ? ( + + ) : ( + <> + + deleteMutation.mutate(m.id)} + /> + )}
-
- - deleteMutation.mutate(m.id)} + {isGrouped && isExpanded && ( + -
+ )}
); })} @@ -807,6 +956,106 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { ); } +/** + * v1.4.37 W7c — lazy drill-down for a collapsed cumulative-type day. + * Fires a separate `GET /api/measurements?type=…&dayKey=…` only when + * the row is expanded; the result is rendered as a nested list of the + * per-sample chunks that summed into the parent row's daily total. + */ +function DayDrillDown({ + type, + dayKey, + unit, + layout, +}: { + type: string; + dayKey: string; + unit: string; + layout: "desktop" | "mobile"; +}) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const { data, isLoading, error } = useQuery({ + queryKey: ["measurement-drilldown", type, dayKey], + queryFn: async () => { + const params = new URLSearchParams(); + params.set("type", type); + params.set("dayKey", dayKey); + params.set("sortDir", "asc"); + const res = await fetch(`/api/measurements?${params}`); + if (!res.ok) throw new Error("Failed to fetch drill-down"); + const json = await res.json(); + return json.data as { measurements: Measurement[] }; + }, + enabled: isAuthenticated, + // The drill-down is per-day — once fetched it rarely needs to + // re-fetch (the underlying day has already stabilised for + // anything older than the drain's 36 h cutoff). Keep it cached + // for the session. + staleTime: 5 * 60 * 1000, + }); + + if (isLoading) { + return ( +
+ + {t("common.loading")} +
+ ); + } + if (error) { + return ( +
+ {t("measurements.saveError")} +
+ ); + } + const rows = data?.measurements ?? []; + if (rows.length === 0) { + return ( +
+ {t("measurements.emptyDescription")} +
+ ); + } + if (layout === "desktop") { + return ( +
+ {rows.map((s) => ( +
+ + {formatDateTime(s.measuredAt)} + + + {s.value} {unit} + +
+ ))} +
+ ); + } + return ( +
+ {rows.map((s) => ( +
+ + {formatDateTime(s.measuredAt)} + + + {s.value} {unit} + +
+ ))} +
+ ); +} + function SortableHead({ column, label,