From 3c0063edb203117db475d062ea81ad31ad12c954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:42:43 +0200 Subject: [PATCH] refactor(measurements): split the measurement list into view state, data, rows and sheet MeasurementList was a 1,586-line component with fourteen pieces of state. Its parts move to components/measurements/list/: - list-model: the row type, page size, labels and formatting helpers - use-measurement-list-filters: URL-owned filters, the value-range draft, page, sort and the page-scoped selection - use-measurement-list-data: the list read, the soft deletes with undo and the selection operations - use-measurement-edit: the edit draft, its unit conversion both ways, the save and the discard guard - use-measurement-row-display: how a row shows its reading, as a pure rule (rowDisplayFor) bound to the account - measurement-filter-rail, measurement-table (one row component), measurement-card-list (one card component), row-actions shared by both layouts, measurement-edit-sheet, day-drill-down measurement-list.tsx lays these out in about 330 lines. Markup and behaviour are unchanged. The glucose edit checks call the conversion functions instead of matching source, and the guards that named the old file name the module that now holds what they check. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 7 +- .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../destructive-control-guard.test.ts | 7 +- .../success-affordance-guard.test.ts | 4 +- .../unit-preference-display-guard.test.ts | 9 +- .../use-seeded-form-dismissal.test.ts | 27 +- .../measurement-list-glucose-unit.test.tsx | 53 +- .../measurements/list/day-drill-down.tsx | 155 ++ .../measurements/list/list-model.ts | 141 ++ .../list/measurement-card-list.tsx | 255 +++ .../list/measurement-edit-sheet.tsx | 265 +++ .../list/measurement-filter-rail.tsx | 112 + .../measurements/list/measurement-table.tsx | 318 +++ .../measurements/list/row-actions.tsx | 89 + .../measurements/list/use-measurement-edit.ts | 268 +++ .../list/use-measurement-list-data.ts | 219 ++ .../list/use-measurement-list-filters.ts | 208 ++ .../list/use-measurement-row-display.ts | 86 + .../measurements/measurement-list.tsx | 2038 ++--------------- 21 files changed, 2389 insertions(+), 1887 deletions(-) create mode 100644 src/components/measurements/list/day-drill-down.tsx create mode 100644 src/components/measurements/list/list-model.ts create mode 100644 src/components/measurements/list/measurement-card-list.tsx create mode 100644 src/components/measurements/list/measurement-edit-sheet.tsx create mode 100644 src/components/measurements/list/measurement-filter-rail.tsx create mode 100644 src/components/measurements/list/measurement-table.tsx create mode 100644 src/components/measurements/list/row-actions.tsx create mode 100644 src/components/measurements/list/use-measurement-edit.ts create mode 100644 src/components/measurements/list/use-measurement-list-data.ts create mode 100644 src/components/measurements/list/use-measurement-list-filters.ts create mode 100644 src/components/measurements/list/use-measurement-row-display.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 3b163dee8..8f2f24790 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 120, - "total": 139784, + "count": 119, + "total": 137765, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -26,7 +26,6 @@ "components/layout/sidebar-nav.tsx": 855, "components/measurement-reminders/vorsorge-section.tsx": 1536, "components/measurements/measurement-form.tsx": 815, - "components/measurements/measurement-list.tsx": 2019, "components/medications/detail/medication-detail-tabs.tsx": 1160, "components/medications/dose-history-ledger.tsx": 1052, "components/medications/medication-table.tsx": 853, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 53d14f450..19a5f68ca 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 408, - "total": 143009, + "count": 405, + "total": 140971, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -180,9 +180,6 @@ "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeCard": 635, "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 618, "components/measurements/measurement-form.tsx :: MeasurementForm": 500, - "components/measurements/measurement-list.tsx :: MeasurementList": 1586, - "components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg)": 223, - "components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg) #2": 229, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 97d3ca3a0..91bc78a7e 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 361, - "total": 361, + "count": 360, + "total": 360, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -214,7 +214,6 @@ "components/layout/__tests__/sidebar-nav.test.tsx": 1, "components/measurements/__tests__/duration-read-as.test.tsx": 1, "components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1, - "components/measurements/__tests__/measurement-list-glucose-unit.test.tsx": 1, "components/medications/__tests__/add-inventory-dialog.test.tsx": 1, "components/medications/__tests__/detail-page-cascade.test.tsx": 1, "components/medications/__tests__/inventory-section-dates.test.tsx": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 76742a6bb..2705ce464 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2952, - "total": 10336, + "count": 2951, + "total": 10297, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1131,7 +1131,6 @@ "components/measurements/__tests__/measurement-list-locked-type.test.tsx": 2, "components/measurements/__tests__/measurement-list-step-grouping.test.tsx": 1, "components/measurements/measurement-form.tsx": 11, - "components/measurements/measurement-list.tsx": 39, "components/medications/__tests__/add-inventory-dialog.test.tsx": 1, "components/medications/__tests__/card-parts.test.tsx": 13, "components/medications/__tests__/detail-page-cascade.test.tsx": 1, diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index d95443811..d7ad190f2 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -350,8 +350,13 @@ const REGISTRY: DestructiveEntry[] = [ // ── Measurements, mood, labs, illness — the undo-and-restore surfaces ──── { - file: "components/measurements/measurement-list.tsx", + file: "components/measurements/list/use-measurement-list-data.ts", destroys: "one measurement, or every selected measurement", + triggers: [ + "components/measurements/measurement-list.tsx", + "components/measurements/list/row-actions.tsx", + "components/measurements/list/measurement-edit-sheet.tsx", + ], recovery: "restorable", confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"], }, diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 361c8afa2..f07694e03 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -233,7 +233,9 @@ const PINNED_AFFORDANCES: Record< "text-success": 1, }, "src/components/measurements/measurement-form.tsx": { "toast.success": 1 }, - "src/components/measurements/measurement-list.tsx": { "toast.success": 3 }, + "src/components/measurements/list/use-measurement-list-data.ts": { + "toast.success": 3, + }, "src/components/medications/card-parts/medication-status-pill.tsx": { "text-success": 1, }, diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index fbc72f0df..a14dbbe91 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -296,10 +296,15 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", }, - "components/measurements/measurement-list.tsx": { - lines: 2, + "components/measurements/list/use-measurement-edit.ts": { + lines: 1, reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL", }, + "components/measurements/list/use-measurement-row-display.ts": { + lines: 1, + reason: + "input parsing: decides whether the inline edit's input is in mmol/L and so needs inverting", + }, "components/settings/import-panel/import-examples.ts": { lines: 3, reason: "input parsing: CSV examples showing the units an import accepts", diff --git a/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts b/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts index 7d499c625..3afb798c2 100644 --- a/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts +++ b/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts @@ -322,11 +322,28 @@ describe("safeInternalNavigationTarget", () => { }); describe("seeded dismissal migrations", () => { - it.each([ - "measurements/measurement-list.tsx", - "mood/mood-list.tsx", - "labs/lab-history-list.tsx", - ])( + it("routes every measurement-list edit exit through the shared contract", () => { + // The list's edit lives in its own hook and sheet. + const hook = readFileSync( + new URL( + "../../measurements/list/use-measurement-edit.ts", + import.meta.url, + ), + "utf8", + ); + const sheet = readFileSync( + new URL( + "../../measurements/list/measurement-edit-sheet.tsx", + import.meta.url, + ), + "utf8", + ); + expect(hook).toContain("useSeededFormDismissal({"); + expect(hook).toContain("editDismissal.requestClose(dismissEdit)"); + expect(sheet).toContain(" { const source = readFileSync( diff --git a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx index 97765257f..ed1d056da 100644 --- a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -63,11 +61,33 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { MeasurementList } from "../measurement-list"; +import type { UnitDisplay } from "@/hooks/use-unit-display"; +import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { editSeedValue, storedValueFor } from "../list/use-measurement-edit"; +import { rowDisplayFor } from "../list/use-measurement-row-display"; -const listSource = readFileSync( - join(process.cwd(), "src/components/measurements/measurement-list.tsx"), - "utf8", -); +/** A metric reader on the given glucose unit, as the list's row display. */ +function displayOn(glucoseUnit: "mg/dL" | "mmol/L") { + const unitDisplay = { + preference: "metric", + isTransformed: () => false, + toDisplay: (_type: string, v: number) => v, + fromDisplay: (_type: string, v: number) => v, + unitFor: (type: string) => getUnitForType(type), + } as unknown as UnitDisplay; + return rowDisplayFor(glucoseUnit, unitDisplay); +} + +const glucose = (value: number) => ({ + id: "g1", + type: "BLOOD_GLUCOSE", + value, + unit: "mg/dL", + source: "MANUAL", + measuredAt: "2026-10-01T08:00:00.000Z", + notes: null, +}); function render() { return renderToStaticMarkup( @@ -96,22 +116,27 @@ describe("MeasurementList — blood-glucose display unit", () => { }); it("inverts an edited glucose value back to mg/dL before the save", () => { - expect(listSource).toMatch( - /editsConvertedGlucose\(editing\)[\s\S]{0,400}?canonicalValue = toCanonicalMgdl\(parsedValue, "mmol\/L"\)/, - ); + // The label above the input says mmol/L, so the number in it is mmol/L; + // sent on unconverted it would rewrite a 95 mg/dL reading as 5.3. + const stored = storedValueFor(displayOn("mmol/L"), glucose(95), 5.3); + expect(stored).toBe(toCanonicalMgdl(5.3, "mmol/L")); + expect(stored).toBeGreaterThan(90); }); it("seeds the edit input in the unit its own label names", () => { - expect(listSource).toMatch( - /const seedValue = editsConvertedGlucose\(measurement\)\s*\?\s*rd\.value/, + const display = displayOn("mmol/L"); + expect(editSeedValue(display, glucose(95))).toBe( + convertGlucose(95, "mmol/L"), ); + expect(display.rowDisplay(glucose(95)).unit).toBe("mmol/L"); }); it("leaves an mg/dL edit on the identity path so a note fix cannot round the value", () => { // `toCanonicalMgdl(v, "mg/dL")` rounds, so routing the mg/dL branch // through it would quantise a stored 95.48 on an unrelated edit. - expect(listSource).toMatch( - /editsConvertedGlucose[\s\S]{0,300}?glucoseUnit === "mmol\/L"/, - ); + const display = displayOn("mg/dL"); + expect(display.editsConvertedGlucose(glucose(95.48))).toBe(false); + expect(editSeedValue(display, glucose(95.48))).toBe(95.48); + expect(storedValueFor(display, glucose(95.48), 95.48)).toBe(95.48); }); }); diff --git a/src/components/measurements/list/day-drill-down.tsx b/src/components/measurements/list/day-drill-down.tsx new file mode 100644 index 000000000..1cda48d4e --- /dev/null +++ b/src/components/measurements/list/day-drill-down.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; + +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { formatDateTime } from "@/lib/format"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { sleepStageLabel, type Measurement } from "./list-model"; + +/** + * The lazy drill-down for a collapsed day or night. + * 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. + */ +export 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, refetch } = useQuery({ + queryKey: queryKeys.measurementDrilldown(type, dayKey), + queryFn: async () => { + const params = new URLSearchParams(); + params.set("type", type); + params.set("dayKey", dayKey); + params.set("sortDir", "asc"); + return apiGet<{ measurements: Measurement[] }>( + `/api/measurements?${params}`, + ); + }, + 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 ( + void refetch()} + className={layout === "desktop" ? "m-2" : "mt-2"} + /> + ); + } + const rows = data?.measurements ?? []; + if (rows.length === 0) { + return ( + + ); + } + // For SLEEP_DURATION the drill-down rows are stage segments; + // render the stage label + minutes instead of a bare value + unit. + const isSleep = type === "SLEEP_DURATION"; + if (layout === "desktop") { + return ( +
+ {rows.map((s) => ( +
+ + {isSleep + ? sleepStageLabel(s.sleepStage, t) + : formatDateTime(s.measuredAt)} + + + {isSleep + ? formatDurationMinutes(s.value, t) + : `${s.value} ${unit}`} + +
+ ))} +
+ ); + } + return ( +
+ {rows.map((s) => ( +
+ + {isSleep + ? sleepStageLabel(s.sleepStage, t) + : formatDateTime(s.measuredAt)} + + + {isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`} + +
+ ))} +
+ ); +} + +/** + * Sub-caption for a per-night sleep row: the row's value is the day's sleep, + * main night plus naps, so the caption says how many naps it includes, and + * the night's mid-sleep awakenings when present. + */ +export function SleepNightCaption({ m }: { m: Measurement }) { + const { t } = useTranslations(); + const napCount = m.napCount ?? 0; + const awakenings = m.awakenings ?? 0; + const parts: string[] = []; + if (napCount > 0) { + parts.push(t("measurements.sleepNapCaption", { count: String(napCount) })); + } + if (awakenings > 0) { + parts.push( + t("measurements.sleepAwakeningsCaption", { count: String(awakenings) }), + ); + } + if (parts.length === 0) return null; + return ( + + {parts.join(" · ")} + + ); +} diff --git a/src/components/measurements/list/list-model.ts b/src/components/measurements/list/list-model.ts new file mode 100644 index 000000000..7fe3519b6 --- /dev/null +++ b/src/components/measurements/list/list-model.ts @@ -0,0 +1,141 @@ +import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; +import type { useTranslations } from "@/lib/i18n/context"; +import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; +import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit"; +import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; + +type Translate = ReturnType["t"]; + +/** + * Cumulative types whose list view collapses to one row per profile-zone day + * with a chevron into the per-sample chunks. Mirrors the server's + * `CUMULATIVE_DAY_SUM_TYPES`, so the route's `groupBy=day` branch and the + * client's filter detection agree. + */ +export const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); + +// Columns that open descending when first selected. The date column reads +// newest-first by default; every other column opens ascending. +export const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set([ + "measuredAt", +]); + +export interface Measurement { + id: string; + type: string; + value: number; + unit: string; + source: string; + measuredAt: string; + notes: string | null; + /** + * Present only on collapsed daily rows (`?type=…&groupBy=day`): drives the + * expand chevron and the drill-down read keyed by the calendar day. + */ + dayKey?: string; + sampleCount?: number; + /** + * Present only on per-night SLEEP_DURATION rows. `value` is the night's + * time asleep in minutes; these carry the night's context so the row + * reads as one night, not many stage values. + */ + sleepStage?: string | null; + napCount?: number; + napAsleepMinutes?: number; + awakenings?: number; +} + +/** A synthetic day or night row: many raw rows behind one `dayKey`. */ +export function isGroupedRow(m: Measurement): boolean { + return m.dayKey !== undefined && m.sampleCount !== undefined; +} + +/** The labels for the sleep stages shown in the drill-down. */ +export function sleepStageLabel( + stage: string | null | undefined, + t: Translate, +): string { + switch (stage) { + case "DEEP": + return t("insights.sleep.stages.deep"); + case "REM": + return t("insights.sleep.stages.rem"); + case "CORE": + return t("insights.sleep.stages.core"); + case "ASLEEP": + return t("insights.sleep.stages.asleep"); + case "AWAKE": + return t("insights.sleep.stages.awake"); + case "IN_BED": + return t("insights.sleep.stages.inBed"); + default: + return stage ?? ""; + } +} + +export const PAGE_SIZE = 25; +// Input cap mirrors the server Zod bound. The list preview truncates at a +// shorter width so a long note does not stretch a list row. +export const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH; +const COMMENT_PREVIEW_LENGTH = 40; + +export function truncateComment(comment: string): string { + if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment; + return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`; +} + +export function toDateTimeLocalValue(isoString: string): string { + const date = new Date(isoString); + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60 * 1000); + return local.toISOString().slice(0, 16); +} + +/** + * The `MeasurementSource` enum through the `measurements.source*` keys, so + * the SCREAMING_SNAKE enum never reaches a cell or the filter. The catalogue + * is keyed by the enum, so a provider added without its label is a type + * error rather than a row that reads `GOOGLE_HEALTH`. + */ +export function formatMeasurementSource(source: string, t: Translate) { + const key = MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS + ] as string | undefined; + // A row written by a server the client does not know yet still renders as + // its own value rather than as a missing translation key. + return key ? t(key) : source; +} + +/** The sources the filter offers, from the shared Zod enum. */ +export const MEASUREMENT_SOURCE_OPTIONS = MEASUREMENT_SOURCES; + +/** + * A date input's `YYYY-MM-DD` as the offset-bearing ISO datetime the list's + * `from` / `to` params need: `from` at the local start of the day, `to` at + * its local end, so a single-day range returns the whole day. + */ +export function dayBoundaryIso( + day: string, + boundary: "start" | "end", +): string | undefined { + if (!day) return undefined; + const [y, m, d] = day.split("-").map(Number); + if (!y || !m || !d) return undefined; + const date = + boundary === "start" + ? new Date(y, m - 1, d, 0, 0, 0, 0) + : new Date(y, m - 1, d, 23, 59, 59, 999); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); +} + +/** + * Per-source badge colour: Apple Health in the pink of the iOS app's accent, + * everything else the outline default. + */ +export function sourceBadgeClass(source: string): string { + if (source === "APPLE_HEALTH") { + return "border-brand-pink/50 bg-brand-pink/15 text-brand-pink"; + } + return ""; +} diff --git a/src/components/measurements/list/measurement-card-list.tsx b/src/components/measurements/list/measurement-card-list.tsx new file mode 100644 index 000000000..78da09634 --- /dev/null +++ b/src/components/measurements/list/measurement-card-list.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DayLink, DayLinkAt } from "@/components/day/day-link"; +import { formatDateOrRelative } from "@/lib/format"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; +import { ListRow } from "@/components/ui/list-row"; +import { measurementUnitMetaLead } from "@/lib/measurements/unit-label"; + +import { + MEASUREMENT_TYPE_COLORS as TYPE_COLORS, + MEASUREMENT_TYPE_ICONS as TYPE_ICONS, +} from "../measurement-list-meta"; + +import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; +import { MeasurementRowActions, typeLabel } from "./row-actions"; +import { + formatMeasurementSource, + isGroupedRow, + sourceBadgeClass, + truncateComment, + type Measurement, +} from "./list-model"; +import type { MeasurementListData } from "./use-measurement-list-data"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** The phone list: one card per reading, day or night. */ +export function MeasurementCardList({ + measurements, + canManage, + filters, + list, + display, + expandedDayKeys, + toggleExpand, + navigateForType, + startEdit, +}: { + measurements: Measurement[]; + canManage: boolean; + filters: MeasurementListFilters; + list: MeasurementListData; + display: MeasurementRowDisplay; + expandedDayKeys: ReadonlySet; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { selectedIds } = filters; + const { onToggleRow, deleteMutation } = list; + return ( +
+ {measurements.map((m) => ( + deleteMutation.mutate(id)} + display={display} + toggleExpand={toggleExpand} + navigateForType={navigateForType} + startEdit={startEdit} + /> + ))} +
+ ); +} + +/** One card, and under a grouped card its open drill-down. */ +function MeasurementCard({ + m, + canManage, + isSelected, + isExpanded, + onToggleRow, + onDelete, + display, + toggleExpand, + navigateForType, + startEdit, +}: { + m: Measurement; + canManage: boolean; + isSelected: boolean; + isExpanded: boolean; + onToggleRow: (id: string) => void; + onDelete: (id: string) => void; + display: MeasurementRowDisplay; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { rowDisplay, rowIdentityAttrs } = display; + const Icon = TYPE_ICONS[m.type]; + const isGrouped = isGroupedRow(m); + const isSleep = m.type === "SLEEP_DURATION"; + const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`; + const label = typeLabel(m.type, t); + // The unit is meta: on a phone a long one ("Atemzüge/min") beside the + // value pushed the row's controls, so it leads the meta line instead. A + // night reads as a duration and has none. + const unitLead = isSleep + ? null + : measurementUnitMetaLead( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + ); + return ( + +
+
+ {!isGrouped && canManage && ( + // The checkbox owns its 44 px hit area through an `after` + // hit-slop inside a plain layout `
`: a wrapper ` + )} +
+ {/* The type names the row: without it two readings in the same + unit ("55 ms" HRV and "63 ms" RMSSD) could not be told apart + on a phone, where the table's type column does not exist. */} +

+ {label} +

+ + {isSleep + ? formatDurationMinutes(m.value, t) + : isGrouped + ? fmt.integer(m.value) + : fmt.number( + rowDisplay(m).value, + rawDisplayFractionDigits(m.type), + )} + + {isSleep ? ( + + ) : ( + isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + ) + )} + {/* The meta line wraps rather than truncates: cut short it hid + the day it opens and the source beside it. */} +

+ {unitLead ? ( + + {unitLead} + + ) : null} + {m.dayKey !== undefined ? ( + + {formatDateOrRelative(m.measuredAt, t)} + + ) : ( + + {formatDateOrRelative(m.measuredAt, t)} + + )} + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )} +

+ {m.notes && ( + // User data is never muted; the table renders the same note in + // the foreground colour. +

+ {truncateComment(m.notes)} +

+ )} +
+
+
+ +
+
+ {isGrouped && isExpanded && ( +
+ +
+ )} + + ); +} diff --git a/src/components/measurements/list/measurement-edit-sheet.tsx b/src/components/measurements/list/measurement-edit-sheet.tsx new file mode 100644 index 000000000..8a8200370 --- /dev/null +++ b/src/components/measurements/list/measurement-edit-sheet.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { Loader2, MoreHorizontal, Trash2 } from "lucide-react"; + +import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DateTimeField } from "@/components/ui/date-time-field"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS } from "../measurement-list-meta"; + +import { MAX_COMMENT_LENGTH, toDateTimeLocalValue } from "./list-model"; +import type { MeasurementEdit } from "./use-measurement-edit"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** + * The edit sheet (a bottom sheet on phones): value in the display unit, + * timestamp, note, and the delete behind the overflow menu, with the + * actions portalled into the sheet's pinned footer. + */ +export function MeasurementEditSheet({ + edit, + display, +}: { + edit: MeasurementEdit; + display: MeasurementRowDisplay; +}) { + const { t } = useTranslations(); + const { rowDisplay } = display; + const { + editing, + closeEdit, + setEditFooterEl, + editFormId, + submitEdit, + editValue, + setEditValue, + editError, + editErrorDescriptor, + editMeasuredAt, + setEditMeasuredAt, + editNotes, + setEditNotes, + editErrorId, + editFooterEl, + deleteMutation, + setEditDeleteDialogOpen, + editDeleteDialogOpen, + deleteEditingMeasurement, + editDismissal, + } = edit; + return ( + <> + !open && closeEdit()} + title={t("measurements.editMeasurement")} + footer={
} + > + {editing && ( +
+
+ + + {TYPE_LABEL_KEYS[editing.type] + ? t(TYPE_LABEL_KEYS[editing.type]) + : editing.type} + +
+ +
+ + setEditValue(e.target.value)} + required + aria-required="true" + aria-invalid={!!editError || undefined} + aria-describedby={editErrorDescriptor} + /> +
+ +
+ + +
+ +
+
+ + + {editNotes.length}/{MAX_COMMENT_LENGTH} + +
+ setEditNotes(e.target.value)} + maxLength={MAX_COMMENT_LENGTH} + enterKeyHint="done" + autoCapitalize="sentences" + /> +
+ + {editError && ( + + )} + + {editFooterEl + ? createPortal(, editFooterEl) + : null} + + + + + + {t("measurements.deleteConfirmTitle")} + + + {t("measurements.deleteConfirmDescription")} + + + + + {t("common.cancel")} + + + {deleteMutation.isPending ? ( + + ) : null} + {t("common.delete")} + + + + + + )} + + + + ); +} + +/** + * The sheet's pinned footer: the overflow menu with delete on the left, + * cancel and save on the right. + */ +function EditSheetFooter({ edit }: { edit: MeasurementEdit }) { + const { t } = useTranslations(); + const { + updateMutation, + deleteMutation, + setEditDeleteDialogOpen, + closeEdit, + editFormId, + } = edit; + return ( +
+ + + + + + setEditDeleteDialogOpen(true)}> + + {t("common.delete")} + + + + + {/* `[Cancel] [Save]`, primary rightmost, per Apple's HIG: iOS is the + load-bearing phone target and the web shell renders desktop and the + iOS PWA from one component. Do not flip it for "Android parity"; + fork a platform shell if Android becomes a real audience. */} +
+ + +
+
+ ); +} diff --git a/src/components/measurements/list/measurement-filter-rail.tsx b/src/components/measurements/list/measurement-filter-rail.tsx new file mode 100644 index 000000000..dd417ad69 --- /dev/null +++ b/src/components/measurements/list/measurement-filter-rail.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { + FilterBar, + FilterBarDateRange, + FilterBarNumberRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS } from "../measurement-list-meta"; + +import { + MEASUREMENT_SOURCE_OPTIONS, + formatMeasurementSource, +} from "./list-model"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; + +/** + * The filter rail: compact pill triggers in the canonical order date range · + * type · source · value range, active filters folded into their pill as + * removable chips, a reset action and the result count. + */ +export function MeasurementFilterRail({ + filters, + total, +}: { + filters: MeasurementListFilters; + total: number | undefined; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { + typeFilter, + sourceFilter, + fromDay, + toDay, + valueMinInput, + valueMaxInput, + isDayGroupedFilter, + resetFilters, + setFromDay, + setToDay, + setTypeFilter, + setSourceFilter, + setValueMinInput, + setValueMaxInput, + } = filters; + return ( + + + ({ + value: val, + label: t(labelKey), + }))} + /> + ({ + value: src, + label: formatMeasurementSource(src, t), + }))} + /> + {/* Value-range pill. Hidden for the day-grouped / + sleep-night types where the painted `value` is a synthesised + SUM / per-night total rather than a raw reading. */} + {!isDayGroupedFilter && ( + + )} + + ); +} diff --git a/src/components/measurements/list/measurement-table.tsx b/src/components/measurements/list/measurement-table.tsx new file mode 100644 index 000000000..1ba980ccd --- /dev/null +++ b/src/components/measurements/list/measurement-table.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DayLink, DayLinkAt } from "@/components/day/day-link"; +import { formatDateOrRelative } from "@/lib/format"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; +import { SortableHead } from "@/components/data-list"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; + +import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS } from "../measurement-list-meta"; + +import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; +import { MeasurementRowActions, typeLabel } from "./row-actions"; +import { + formatMeasurementSource, + isGroupedRow, + sourceBadgeClass, + truncateComment, + type Measurement, +} from "./list-model"; +import type { MeasurementListData } from "./use-measurement-list-data"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** The desktop table: one row per reading, day or night, sortable columns. */ +export function MeasurementTable({ + measurements, + canManage, + filters, + list, + display, + expandedDayKeys, + toggleExpand, + navigateForType, + startEdit, +}: { + measurements: Measurement[]; + canManage: boolean; + filters: MeasurementListFilters; + list: MeasurementListData; + display: MeasurementRowDisplay; + expandedDayKeys: ReadonlySet; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { t } = useTranslations(); + const { sortBy, sortDir, toggleSort, selectedIds } = filters; + const { selectAll, pageIds, onToggleSelectAll, onToggleRow, deleteMutation } = + list; + return ( +
+ + + + + {/* Select all on the page. Bulk delete is the owner's alone, + so a delegate gets no selection column. */} + {canManage && ( + + )} + + {t("measurements.type")} + + + {t("measurements.comment")} + + + {t("measurements.actions")} + + + + + {measurements.map((m) => ( + deleteMutation.mutate(id)} + display={display} + toggleExpand={toggleExpand} + navigateForType={navigateForType} + startEdit={startEdit} + /> + ))} + +
+
+ ); +} + +/** One table row, and under a grouped row its open drill-down. */ +function MeasurementTableRow({ + m, + canManage, + isSelected, + isExpanded, + onToggleRow, + onDelete, + display, + toggleExpand, + navigateForType, + startEdit, +}: { + m: Measurement; + canManage: boolean; + isSelected: boolean; + isExpanded: boolean; + onToggleRow: (id: string) => void; + onDelete: (id: string) => void; + display: MeasurementRowDisplay; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { rowDisplay, rowIdentityAttrs } = display; + const isGrouped = isGroupedRow(m); + // A sleep night is grouped (its chevron opens the stage segments) but + // reads as a duration with a nap caption, not as a daily total. + const isSleep = m.type === "SLEEP_DURATION"; + // A stable id, so the chevron's `aria-controls` names the panel it opens. + const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`; + const label = typeLabel(m.type, t); + return ( + <> + + + {/* A grouped row is not individually deletable: no checkbox. */} + {!isGrouped && canManage && ( + onToggleRow(m.id)} + aria-label={t("dataList.selectRow")} + /> + )} + + + + + + + + {/* A reading keeps its native precision (78.4 kg stays 78.4); a + day total of a cumulative type is a whole number; a night + reads as a duration ("8h 12m") with its nap caption. */} + {isSleep ? ( + <> + {formatDurationMinutes(m.value, t)} + + + ) : ( + <> + {isGrouped + ? fmt.integer(m.value) + : fmt.number( + rowDisplay(m).value, + rawDisplayFractionDigits(m.type), + )}{" "} + {measurementUnitLabel( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + )} + {isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + )} + + )} + + {/* Relative inside the last 24 hours ("3 min ago"), so the list and + the dashboard never phrase "when" differently. The date opens its + day; the row keeps opening the reading. */} + + {m.dayKey !== undefined ? ( + + {formatDateOrRelative(m.measuredAt, t)} + + ) : ( + + {formatDateOrRelative(m.measuredAt, t)} + + )} + + + {m.notes ? ( + {truncateComment(m.notes)} + ) : ( + - + )} + + + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )} + + +
+ +
+
+
+ {isGrouped && isExpanded && ( + + + + + + )} + + ); +} diff --git a/src/components/measurements/list/row-actions.tsx b/src/components/measurements/list/row-actions.tsx new file mode 100644 index 000000000..143d65562 --- /dev/null +++ b/src/components/measurements/list/row-actions.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { ChevronDown, Pencil } from "lucide-react"; + +import { DeleteButton } from "@/components/data-list"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS } from "../measurement-list-meta"; + +import type { Measurement } from "./list-model"; + +type Translate = ReturnType["t"]; + +/** A type's name, or the raw type for one the catalogue does not know. */ +export function typeLabel(type: string, t: Translate): string { + return TYPE_LABEL_KEYS[type] ? t(TYPE_LABEL_KEYS[type]) : type; +} + +/** + * A row's controls: the chevron into a day's or night's samples for a + * grouped row; edit (owner only) and delete for a reading. The table's + * buttons are 40 px, the phone list's the large icon size. + */ +export function MeasurementRowActions({ + m, + isGrouped, + isExpanded, + drilldownId, + canManage, + layout, + toggleExpand, + startEdit, + onDelete, +}: { + m: Measurement; + isGrouped: boolean; + isExpanded: boolean; + drilldownId: string; + canManage: boolean; + layout: "desktop" | "mobile"; + toggleExpand: (dayKey: string) => void; + startEdit: (m: Measurement) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslations(); + const desktop = layout === "desktop"; + const size = desktop ? "icon" : "icon-lg"; + const className = desktop ? "h-10 w-10" : undefined; + return isGrouped ? ( + + ) : ( + <> + {canManage && ( + + )} + onDelete(m.id)} + title={t("measurements.deleteConfirmTitle")} + description={t("measurements.deleteConfirmDescription")} + /> + + ); +} diff --git a/src/components/measurements/list/use-measurement-edit.ts b/src/components/measurements/list/use-measurement-edit.ts new file mode 100644 index 000000000..e7ae637e7 --- /dev/null +++ b/src/components/measurements/list/use-measurement-edit.ts @@ -0,0 +1,268 @@ +"use client"; + +import { useId, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { useSeededFormDismissal } from "@/components/forms/use-seeded-form-dismissal"; +import { ApiError, apiPut } from "@/lib/api/api-fetch"; +import { toCanonicalMgdl } from "@/lib/glucose"; +import { useTranslations } from "@/lib/i18n/context"; +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { + invalidateKeys, + measurementDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; + +import { + MAX_COMMENT_LENGTH, + toDateTimeLocalValue, + type Measurement, +} from "./list-model"; +import type { MeasurementListData } from "./use-measurement-list-data"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** + * The edit input's seed, in the preferred unit, so an imperial reader edits + * in lb / in / °F (converted back on save), rounded so the input shows "210" + * rather than a long float. Metric readers and legacy rows see the stored + * value. + */ +export function editSeedValue( + { + unitDisplay, + editsConvertedGlucose, + rowDisplay, + }: Pick< + MeasurementRowDisplay, + "unitDisplay" | "editsConvertedGlucose" | "rowDisplay" + >, + measurement: Measurement, +): number { + const rd = rowDisplay(measurement); + return editsConvertedGlucose(measurement) + ? rd.value + : unitDisplay.preference === "imperial" && + unitDisplay.isTransformed(measurement.type) && + measurement.unit === getUnitForType(measurement.type) + ? Math.round(rd.value * 100) / 100 + : measurement.value; +} + +/** + * The input's display-unit value inverted to the stored unit before the + * request. Metric readers and legacy rows pass unchanged. + */ +export function storedValueFor( + { + unitDisplay, + editsConvertedGlucose, + }: Pick, + editing: Measurement, + parsedValue: number, +): number { + if (editsConvertedGlucose(editing)) { + // The label said mmol/L, so the number is mmol/L. Sending it on would + // rewrite a 95 mg/dL reading as 5.3. + return toCanonicalMgdl(parsedValue, "mmol/L"); + } + if ( + unitDisplay.isTransformed(editing.type) && + editing.unit === getUnitForType(editing.type) + ) { + const inverted = unitDisplay.fromDisplay(editing.type, parsedValue); + return unitDisplay.preference === "imperial" + ? Math.round(inverted * 100) / 100 + : inverted; + } + return parsedValue; +} + +/** + * The edit sheet's state: the reading being edited, the draft seeded in the + * person's display unit, the save (converted back to the stored unit) and + * the delete from inside the sheet, and the dismissal guard that asks before + * an edited draft is thrown away. + */ +export function useMeasurementEdit({ + onEdit, + display, + deleteMutation, +}: { + onEdit?: (m: Measurement) => void; + display: MeasurementRowDisplay; + deleteMutation: MeasurementListData["deleteMutation"]; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + const [editValue, setEditValue] = useState(""); + const [editMeasuredAt, setEditMeasuredAt] = useState(""); + const [editNotes, setEditNotes] = useState(""); + const [editSeed, setEditSeed] = useState({ + value: "", + measuredAt: "", + notes: "", + }); + const [editError, setEditError] = useState(null); + const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); + // The error banner is linked to the inputs through `aria-describedby`, so a + // screen reader announces why a submit was refused. + const editErrorId = useId(); + // The sheet's footer is a slot the form portals its buttons into. + const editFormId = useId(); + const [editFooterEl, setEditFooterEl] = useState(null); + + const updateMutation = useMutation({ + mutationFn: async ({ + id, + value, + measuredAt, + notes, + }: { + id: string; + value: number; + measuredAt: string; + notes: string | null; + }) => { + await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes }); + }, + onSuccess: async () => { + await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); + setEditSeed({ + value: editValue, + measuredAt: editMeasuredAt, + notes: editNotes, + }); + dismissEdit(); + }, + onError: (err) => { + // A 409 names its reason in `meta.errorCode`: the edit collides with + // another reading's (type, time, source, stage), or the reading belongs + // to a server-owned source (connector, import, computed). + if ( + err instanceof ApiError && + err.meta?.errorCode === "measurement.duplicate_timestamp" + ) { + setEditError(t("measurements.duplicateTimestamp")); + return; + } + if ( + err instanceof ApiError && + err.meta?.errorCode === "measurement.update.server_owned_source" + ) { + setEditError(t("measurements.serverOwnedSource")); + return; + } + setEditError( + err instanceof Error ? err.message : t("measurements.saveError"), + ); + }, + }); + const editDismissal = useSeededFormDismissal({ + seed: editSeed, + value: { + value: editValue, + measuredAt: editMeasuredAt, + notes: editNotes, + }, + blocked: updateMutation.isPending || deleteMutation.isPending, + }); + + function startEdit(measurement: Measurement) { + if (onEdit) { + onEdit(measurement); + return; + } + + const seedValue = editSeedValue(display, measurement); + const seed = { + value: String(seedValue), + measuredAt: toDateTimeLocalValue(measurement.measuredAt), + notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH), + }; + setEditing(measurement); + setEditValue(seed.value); + setEditMeasuredAt(seed.measuredAt); + setEditNotes(seed.notes); + setEditSeed(seed); + setEditError(null); + } + + function dismissEdit() { + setEditing(null); + setEditError(null); + setEditDeleteDialogOpen(false); + } + + function closeEdit() { + editDismissal.requestClose(dismissEdit); + } + + async function deleteEditingMeasurement() { + if (!editing) return; + + try { + setEditError(null); + await deleteMutation.mutateAsync(editing.id); + dismissEdit(); + } catch { + setEditError(t("measurements.deleteError")); + } + } + + async function submitEdit(e: React.FormEvent) { + e.preventDefault(); + if (!editing) return; + + const parsedValue = Number(editValue); + if (!Number.isFinite(parsedValue)) { + setEditError(t("measurements.invalidValue")); + return; + } + + const measuredDate = new Date(editMeasuredAt); + if (Number.isNaN(measuredDate.getTime())) { + setEditError(t("measurements.invalidTimestamp")); + return; + } + + const canonicalValue = storedValueFor(display, editing, parsedValue); + + setEditError(null); + updateMutation.mutate({ + id: editing.id, + value: canonicalValue, + measuredAt: measuredDate.toISOString(), + notes: editNotes.trim() ? editNotes.trim() : null, + }); + } + + return { + editing, + editValue, + setEditValue, + editMeasuredAt, + setEditMeasuredAt, + editNotes, + setEditNotes, + editError, + editErrorId, + editErrorDescriptor: editError ? editErrorId : undefined, + editFormId, + editFooterEl, + setEditFooterEl, + editDeleteDialogOpen, + setEditDeleteDialogOpen, + editDismissal, + updateMutation, + deleteMutation, + startEdit, + closeEdit, + deleteEditingMeasurement, + submitEdit, + }; +} + +export type MeasurementEdit = ReturnType; diff --git a/src/components/measurements/list/use-measurement-list-data.ts b/src/components/measurements/list/use-measurement-list-data.ts new file mode 100644 index 000000000..81c2db1e9 --- /dev/null +++ b/src/components/measurements/list/use-measurement-list-data.ts @@ -0,0 +1,219 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { + selectAllState, + selectedCountOnPage, + selectedIdsOnPage, + toggleId, + toggleSelectAll, +} from "@/components/data-list"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; + +import { PAGE_SIZE, isGroupedRow, type Measurement } from "./list-model"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; + +/** + * The list's read and its deletes. Deletes are soft (tombstones), so each + * success toast carries a real Undo that restores the ids and re-fires the + * same dependent-key bundle, keeping the dashboard, insights and chart + * caches in step. + */ +export function useMeasurementListData({ + filters, + enabled, + lockedType, + onEmptyChange, +}: { + filters: MeasurementListFilters; + enabled: boolean; + lockedType: string | undefined; + onEmptyChange?: (empty: boolean) => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { + typeFilter, + sourceEq, + fromIso, + toIso, + valueMin, + valueMax, + isDayGroupedFilter, + isCumulativeFilter, + page, + sortBy, + sortDir, + listMode, + selectedIds, + setSelectedIds, + clearSelection, + } = filters; + + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: queryKeys.measurementsList({ + type: typeFilter === "ALL" ? undefined : typeFilter, + sourceEq, + from: fromIso, + to: toIso, + // Value range applies only to the raw per-sample list. + valueMin: isDayGroupedFilter ? undefined : valueMin, + valueMax: isDayGroupedFilter ? undefined : valueMax, + page, + sortBy, + sortDir, + mode: listMode, + }), + queryFn: async () => { + const params = new URLSearchParams(); + if (typeFilter !== "ALL") params.set("type", typeFilter); + if (sourceEq) params.set("sourceEq", sourceEq); + if (fromIso) params.set("from", fromIso); + if (toIso) params.set("to", toIso); + if (!isDayGroupedFilter && valueMin != null) + params.set("valueMin", String(valueMin)); + if (!isDayGroupedFilter && valueMax != null) + params.set("valueMax", String(valueMax)); + params.set("limit", String(PAGE_SIZE)); + params.set("offset", String((page - 1) * PAGE_SIZE)); + params.set("sortBy", sortBy); + params.set("sortDir", sortDir); + // The day-grouped modes page over days, 25 per page like the + // per-sample list: `groupBy=day` folds each cumulative day to its sum, + // and sleep collapses to one row per night without a param. + if (isCumulativeFilter) params.set("groupBy", "day"); + return apiGet<{ + measurements: Measurement[]; + meta: { total: number }; + }>(`/api/measurements?${params}`); + }, + enabled, + }); + + const listEmpty = + !isLoading && + !isError && + !data?.measurements?.length && + (typeFilter === "ALL" || !!lockedType); + useEffect(() => { + onEmptyChange?.(listEmpty); + }, [listEmpty, onEmptyChange]); + + const restoreMeasurements = useCallback( + async (ids: string[]) => { + try { + await apiPost("/api/measurements/restore", { ids }); + await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); + toast.success(t("measurements.restoredToast")); + } catch (failure) { + showErrorToast(t("measurements.restoreError"), { cause: failure }); + } + }, + [queryClient, t], + ); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await apiDelete(`/api/measurements/${id}`); + }, + onSuccess: (_data, id) => { + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); + toast.success(t("measurements.deletedToast"), { + action: { + label: t("common.undo"), + onClick: () => void restoreMeasurements([id]), + }, + }); + }, + // A failed row delete says so; it used to fail without a word. + onError: (failure) => { + showErrorToast(t("measurements.deleteError"), { cause: failure }); + }, + }); + + // The page-scoped bulk delete: the selected ids intersected with the + // painted page. + const bulkDeleteMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const data = await apiPost<{ deleted: number }>( + "/api/measurements/bulk-delete", + { ids }, + ); + return data.deleted; + }, + onSuccess: async (deleted, ids) => { + await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); + clearSelection(); + toast.success( + deleted === 1 + ? t("measurements.bulkDeleteSuccessOne") + : t("measurements.bulkDeleteSuccess", { count: String(deleted) }), + { + action: { + label: t("common.undo"), + onClick: () => void restoreMeasurements(ids), + }, + }, + ); + }, + onError: (failure) => { + showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); + }, + }); + + // Synthetic day and night rows collapse many raw rows behind a `dayKey` + // and are not individually deletable, so they are not selectable. + const pageIds = (data?.measurements ?? []) + .filter((m) => !isGroupedRow(m)) + .map((m) => m.id); + const selectAll = selectAllState(selectedIds, pageIds); + const selectedOnPage = selectedCountOnPage(selectedIds, pageIds); + + const onToggleRow = (id: string) => { + setSelectedIds((prev) => toggleId(prev, id)); + }; + const onToggleSelectAll = () => { + setSelectedIds((prev) => toggleSelectAll(prev, pageIds)); + }; + const onConfirmBulkDelete = () => { + const ids = selectedIdsOnPage(selectedIds, pageIds); + if (ids.length === 0) return; + bulkDeleteMutation.mutate(ids); + }; + + // Every mode pages server-side at PAGE_SIZE: rows, days or nights. + const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; + + return { + data, + isLoading, + isError, + error, + refetch, + deleteMutation, + bulkDeleteMutation, + pageIds, + selectAll, + selectedOnPage, + onToggleRow, + onToggleSelectAll, + onConfirmBulkDelete, + totalPages, + }; +} + +export type MeasurementListData = ReturnType; diff --git a/src/components/measurements/list/use-measurement-list-filters.ts b/src/components/measurements/list/use-measurement-list-filters.ts new file mode 100644 index 000000000..b77ed5ab9 --- /dev/null +++ b/src/components/measurements/list/use-measurement-list-filters.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { useTableSort } from "@/hooks/use-table-sort"; +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; + +import { + measurementListFiltersToSearch, + numericInputOrUndefined, + parseMeasurementListSearchParams, +} from "../measurement-list-filters"; + +import { + CUMULATIVE_TYPES, + MEASUREMENT_DESC_COLUMNS, + dayBoundaryIso, +} from "./list-model"; + +/** + * The list's view state: the filters, the page, the sort and the page-scoped + * selection, and what the read derives from them. + * + * Filters are URL-owned (`?type&source&from&to&min&max`, the documents-vault + * pattern), so a filtered view survives navigation, reload and back/forward, + * and the `?type=` deep links simply are the filter state. With `lockedType` + * the list is pinned to one metric, the URL belongs to the page around it, + * and the remaining filters stay in memory. + * + * Every filter, page or sort change resets to page 1 and clears the + * selection, whose rows are about to unmount; the selection never reaches + * past the painted page. + */ +export function useMeasurementListFilters(lockedType: string | undefined) { + const { filters, applyFilters, patchFilters } = useUrlFilterSync({ + parse: parseMeasurementListSearchParams, + serialise: measurementListFiltersToSearch, + syncToUrl: lockedType === undefined, + }); + const typeFilter = lockedType ?? filters.type ?? "ALL"; + // `ALL` clears the source filter; an empty date string clears its bound. + const sourceFilter = filters.source ?? "ALL"; + const fromDay = filters.fromDay ?? ""; + const toDay = filters.toDay ?? ""; + // The value-range inputs fire per keystroke, so they edit a local draft + // that debounces into the URL (200 ms, replace: never a history entry per + // keystroke); an external URL change (back button, deep link) re-seeds the + // draft through the render-phase adjustment below, like the vault's search. + const urlValueRange = `${filters.valueMin ?? ""}\u0000${filters.valueMax ?? ""}`; + const [valueDraft, setValueDraft] = useState(() => ({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + })); + const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange); + if (urlValueRange !== lastUrlValueRange) { + setLastUrlValueRange(urlValueRange); + setValueDraft({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + }); + } + const [page, setPage] = useState(1); + // Shared column-sort state (measuredAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "measuredAt", + defaultDir: "desc", + descColumns: MEASUREMENT_DESC_COLUMNS, + }); + + // The ids selected on the CURRENT page. Every read intersects it with the + // painted page, so the bulk payload is page-bounded (at most a page, well + // under the server's cap of 200) and an id that falls off the page after + // a refetch simply stops counting. + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const clearSelection = () => setSelectedIds(new Set()); + + // Discrete facet changes push (Back steps through filter states, like the + // vault's chips); the committed date bounds replace, so a from/to pair set + // in one sitting does not cost two Back presses. + const resetPage = () => { + setPage(1); + clearSelection(); + }; + const setTypeFilter = (value: string) => { + patchFilters({ type: value === "ALL" ? undefined : value }); + resetPage(); + }; + const setSourceFilter = (value: string) => { + patchFilters({ source: value === "ALL" ? undefined : value }); + resetPage(); + }; + const setFromDay = (value: string) => { + patchFilters({ fromDay: value || undefined }, "replace"); + resetPage(); + }; + const setToDay = (value: string) => { + patchFilters({ toDay: value || undefined }, "replace"); + resetPage(); + }; + const setValueMinInput = (value: string) => { + setValueDraft((prev) => ({ ...prev, min: value })); + resetPage(); + }; + const setValueMaxInput = (value: string) => { + setValueDraft((prev) => ({ ...prev, max: value })); + resetPage(); + }; + // One push clears every facet (and so one Back restores them); the value + // draft is cleared directly, since a bound that never reached the URL has + // no change to re-seed from. + const resetFilters = () => { + applyFilters({}); + setValueDraft({ min: "", max: "" }); + resetPage(); + }; + + // The draft debounces into the URL; a bound that does not normalise to a + // finite number never reaches it (non-numeric input means "unset"). + useEffect(() => { + const handle = setTimeout(() => { + const min = numericInputOrUndefined(valueDraft.min); + const max = numericInputOrUndefined(valueDraft.max); + if ( + (min ?? "") === (filters.valueMin ?? "") && + (max ?? "") === (filters.valueMax ?? "") + ) { + return; + } + patchFilters({ valueMin: min, valueMax: max }, "replace"); + }, 200); + return () => clearTimeout(handle); + }, [valueDraft, filters, patchFilters]); + + const goToPage = (updater: (p: number) => number) => { + setPage(updater); + clearSelection(); + }; + + // The rows re-order and the list snaps back to page 1. + function toggleSort(column: string) { + applySort(column); + resetPage(); + } + + // A cumulative type (steps, active energy, distance, flights, daylight) + // collapses to one row per profile-zone day through the route's + // `groupBy=day` mode, never listing per-sample chunks by default; sleep + // collapses server-side to one row per night (time asleep). Both page + // server-side over their synthesised rows. + const isCumulativeFilter = CUMULATIVE_TYPES.has(typeFilter); + const isSleepFilter = typeFilter === "SLEEP_DURATION"; + const isDayGroupedFilter = isCumulativeFilter || isSleepFilter; + + const fromIso = dayBoundaryIso(fromDay, "start"); + const toIso = dayBoundaryIso(toDay, "end"); + const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter; + // The value range applies to the raw per-sample list only: a day sum or a + // night total is not a raw `value`, so its pill is hidden for those types. + const valueMin = + filters.valueMin !== undefined ? Number(filters.valueMin) : undefined; + const valueMax = + filters.valueMax !== undefined ? Number(filters.valueMax) : undefined; + const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter + ? "sleep-night" + : isCumulativeFilter + ? "groupBy=day" + : "raw"; + + return { + typeFilter, + sourceFilter, + fromDay, + toDay, + valueMinInput: valueDraft.min, + valueMaxInput: valueDraft.max, + page, + sortBy, + sortDir, + selectedIds, + setSelectedIds, + clearSelection, + setTypeFilter, + setSourceFilter, + setFromDay, + setToDay, + setValueMinInput, + setValueMaxInput, + resetFilters, + goToPage, + toggleSort, + isCumulativeFilter, + isDayGroupedFilter, + fromIso, + toIso, + sourceEq, + valueMin, + valueMax, + listMode, + }; +} + +export type MeasurementListFilters = ReturnType< + typeof useMeasurementListFilters +>; diff --git a/src/components/measurements/list/use-measurement-row-display.ts b/src/components/measurements/list/use-measurement-row-display.ts new file mode 100644 index 000000000..042a6ee8f --- /dev/null +++ b/src/components/measurements/list/use-measurement-row-display.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback, useMemo } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; +import { + convertGlucose, + resolveGlucoseUnit, + type GlucoseUnit, +} from "@/lib/glucose"; +import { getUnitForType } from "@/lib/measurements/unit-map"; + +import type { Measurement } from "./list-model"; + +/** + * How a row shows its reading: in the person's preferred unit (kg ↔ lb, + * cm ↔ in, °C ↔ °F) for a canonical row, verbatim for a legacy row whose + * stored unit is not the type's canonical one (so a stored unit is never + * silently relabelled). Glucose sits on its own axis, stored in mg/dL and + * shown in whichever of mg/dL and mmol/L the account chose, so the list and + * the dashboard tile agree about the same reading. + * + * `rowDisplayFor` is that rule for one glucose unit and one metric / + * imperial preference; the hook binds it to the account. + */ +export function rowDisplayFor( + glucoseUnit: GlucoseUnit, + unitDisplay: UnitDisplay, +) { + // An edit whose input is NOT in the stored unit. Only the mmol/L branch + // qualifies: an mg/dL account edits in the unit the column holds, and + // converting anyway would quantise a stored 95.48 to 95 the moment somebody + // opened the sheet to fix a note. + const editsConvertedGlucose = (m: { type: string; unit: string }) => + m.type === "BLOOD_GLUCOSE" && + glucoseUnit === "mmol/L" && + m.unit === getUnitForType(m.type); + const rowDisplay = (m: { type: string; value: number; unit: string }) => { + if (m.unit !== getUnitForType(m.type)) { + return { value: m.value, unit: m.unit }; + } + if (m.type === "BLOOD_GLUCOSE") { + return { + value: convertGlucose(m.value, glucoseUnit), + unit: glucoseUnit, + }; + } + return { + value: unitDisplay.toDisplay(m.type, m.value), + unit: unitDisplay.unitFor(m.type), + }; + }; + return { unitDisplay, editsConvertedGlucose, rowDisplay }; +} + +export function useMeasurementRowDisplay() { + const { user } = useAuth(); + const unitDisplay = useUnitDisplay(); + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); + const { editsConvertedGlucose, rowDisplay } = useMemo( + () => rowDisplayFor(glucoseUnit, unitDisplay), + [glucoseUnit, unitDisplay], + ); + /** + * A machine-readable mirror of what the row renders: the same number and + * unit, before `Intl` turns them into locale text. A browser test that has + * to assert a stored unit would otherwise pin a locale or match a loose + * number. The desktop table and the mobile list emit the same set, so an + * assertion holds at either viewport. + */ + const rowIdentityAttrs = useCallback( + (m: Measurement, isGrouped: boolean) => ({ + "data-testid": "measurement-row", + "data-measurement-type": m.type, + "data-measurement-value": String( + isGrouped ? m.value : rowDisplay(m).value, + ), + "data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit, + }), + [rowDisplay], + ); + return { unitDisplay, editsConvertedGlucose, rowDisplay, rowIdentityAttrs }; +} + +export type MeasurementRowDisplay = ReturnType; diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 6ebe3eeec..ac65e359e 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1,198 +1,38 @@ "use client"; -import { accountLabel } from "@/lib/sharing/account-access-view"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useIsMobile } from "@/hooks/use-is-mobile"; +import { Activity, ChevronLeft, ChevronRight, Plus } from "lucide-react"; + +import { SelectionActionBar } from "@/components/data-list"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; -import { ListRow } from "@/components/ui/list-row"; -import { - FilterBar, - FilterBarDateRange, - FilterBarNumberRange, - FilterBarSelect, -} from "@/components/ui/filter-bar"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { - SeededFormDiscardDialog, - useSeededFormDismissal, -} from "@/components/forms/use-seeded-form-dismissal"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Activity, - Loader2, - Pencil, - Plus, - Trash2, - ChevronLeft, - ChevronRight, - ChevronDown, - MoreHorizontal, -} from "lucide-react"; -import { Fragment, useCallback, useEffect, useId, useState } from "react"; -import { useTableSort } from "@/hooks/use-table-sort"; -import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; -import { createPortal } from "react-dom"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; -import { formatDateOrRelative, formatDateTime } from "@/lib/format"; -import { DayLink, DayLinkAt } from "@/components/day/day-link"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; -import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; -import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; -import { getUnitForType } from "@/lib/measurements/unit-map"; -import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { - convertGlucose, - resolveGlucoseUnit, - toCanonicalMgdl, -} from "@/lib/glucose"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, - refetchInactiveDailyReads, -} from "@/lib/query-keys"; -import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit"; -import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; -import { DateTimeField } from "@/components/ui/date-time-field"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - SortableHead, - DeleteButton, - SelectionActionBar, - toggleId, - toggleSelectAll, - selectAllState, - selectedIdsOnPage, - selectedCountOnPage, -} from "@/components/data-list"; -import { - MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS, - MEASUREMENT_TYPE_ICONS as TYPE_ICONS, - MEASUREMENT_TYPE_COLORS as TYPE_COLORS, -} from "./measurement-list-meta"; -import { - measurementUnitLabel, - measurementUnitMetaLead, -} from "@/lib/measurements/unit-label"; -import { - measurementListFiltersToSearch, - numericInputOrUndefined, - parseMeasurementListSearchParams, -} from "./measurement-list-filters"; -import { - ApiError, - apiDelete, - apiGet, - apiPost, - apiPut, -} from "@/lib/api/api-fetch"; +import { useAuth } from "@/hooks/use-auth"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { INSIGHTS_OVERVIEW_PATH, subPageSlugForType, } from "@/lib/insights/sub-page-metric"; -import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { accountLabel } from "@/lib/sharing/account-access-view"; -/** - * v1.4.37 — 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); - -// Columns that open descending when first selected. The date column reads -// newest-first by default; every other column opens ascending. -const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set(["measuredAt"]); - -interface Measurement { - id: string; - type: string; - value: number; - unit: string; - source: string; - measuredAt: string; - notes: string | null; - /** - * v1.4.37 — 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; - /** - * v1.11.5 — present only on per-night SLEEP_DURATION rows. `value` is - * the night's TIME ASLEEP in minutes; these fields carry the night's - * context so the row reads as one night, not many stage values. - */ - sleepStage?: string | null; - napCount?: number; - napAsleepMinutes?: number; - awakenings?: number; -} - -/** v1.11.5 — i18n labels for the sleep stages shown in the drill-down. */ -function sleepStageLabel( - stage: string | null | undefined, - t: ReturnType["t"], -): string { - switch (stage) { - case "DEEP": - return t("insights.sleep.stages.deep"); - case "REM": - return t("insights.sleep.stages.rem"); - case "CORE": - return t("insights.sleep.stages.core"); - case "ASLEEP": - return t("insights.sleep.stages.asleep"); - case "AWAKE": - return t("insights.sleep.stages.awake"); - case "IN_BED": - return t("insights.sleep.stages.inBed"); - default: - return stage ?? ""; - } -} +import { MeasurementCardList } from "./list/measurement-card-list"; +import { MeasurementEditSheet } from "./list/measurement-edit-sheet"; +import { MeasurementFilterRail } from "./list/measurement-filter-rail"; +import { MeasurementTable } from "./list/measurement-table"; +import type { Measurement } from "./list/list-model"; +import { useMeasurementEdit } from "./list/use-measurement-edit"; +import { useMeasurementListData } from "./list/use-measurement-list-data"; +import { useMeasurementListFilters } from "./list/use-measurement-list-filters"; +import { useMeasurementRowDisplay } from "./list/use-measurement-row-display"; interface MeasurementListProps { onEdit?: (m: Measurement) => void; /** - * v1.4.15: optional callback wired by the parent page so + * Optional callback wired by the parent page so * the empty-state's "Add your first measurement" CTA opens the same * dialog the header button does. When undefined we hide the button * (fallback to the header CTA) instead of crashing. @@ -204,7 +44,7 @@ interface MeasurementListProps { */ onEmptyChange?: (empty: boolean) => void; /** - * v1.8.5 — pin the list to a single `MeasurementType` and hide the + * Pin the list to a single `MeasurementType` and hide the * type selector. Used by the insights "all readings" subpage, which * already knows the metric from the route and wants a focused list * (pagination + inline edit/delete) rather than the global filter UI. @@ -214,83 +54,12 @@ interface MeasurementListProps { lockedType?: string; } -const PAGE_SIZE = 25; -// Input cap mirrors the server Zod bound. The list preview truncates at -// a shorter width so a long note does not stretch a list row. -const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH; -const COMMENT_PREVIEW_LENGTH = 40; - -function truncateComment(comment: string): string { - if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment; - return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`; -} - -function toDateTimeLocalValue(isoString: string): string { - const date = new Date(isoString); - const offset = date.getTimezoneOffset(); - const local = new Date(date.getTime() - offset * 60 * 1000); - return local.toISOString().slice(0, 16); -} - /** - * Render the `MeasurementSource` enum using the `measurements.source*` - * translation keys instead of leaking the SCREAMING_SNAKE enum into the - * table cell or the filter dropdown. The catalogue is keyed by the enum, - * so a provider added without its label is a type error rather than a row - * that reads `GOOGLE_HEALTH`. + * Every reading, filterable, sortable and paged, with inline edit and + * delete. The view state, the read, the edit and the row display live in + * `./list/`; this component lays out the rail, the rows, the selection bar, + * the pagination and the edit sheet. */ -function formatMeasurementSource( - source: string, - t: ReturnType["t"], -) { - const key = MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ - source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS - ] as string | undefined; - // A row written by a server the client does not know yet still renders - // as its own value rather than as a missing translation key. - return key ? t(key) : source; -} - -/** - * v1.15.13 — the `MeasurementSource` enum values offered in the - * management-list source filter. Derived from the shared Zod enum so a - * future source addition surfaces here automatically. - */ -const MEASUREMENT_SOURCE_OPTIONS = MEASUREMENT_SOURCES; - -/** - * v1.15.13 — convert a `` value (YYYY-MM-DD) into the - * offset-bearing ISO datetime the measurements list `from`/`to` params - * require. `from` snaps to the local start-of-day, `to` to the local - * end-of-day so an inclusive single-day range returns that whole day. - */ -function dayBoundaryIso( - day: string, - boundary: "start" | "end", -): string | undefined { - if (!day) return undefined; - const [y, m, d] = day.split("-").map(Number); - if (!y || !m || !d) return undefined; - const date = - boundary === "start" - ? new Date(y, m - 1, d, 0, 0, 0, 0) - : new Date(y, m - 1, d, 23, 59, 59, 999); - if (Number.isNaN(date.getTime())) return undefined; - return date.toISOString(); -} - -/** - * Per-source badge colour. `APPLE_HEALTH` gets the Dracula pink that - * matches the iOS app's accent; everything else falls back to the - * shadcn outline default (no class override). - */ -function sourceBadgeClass(source: string): string { - if (source === "APPLE_HEALTH") { - return "border-brand-pink/50 bg-brand-pink/15 text-brand-pink"; - } - return ""; -} - export function MeasurementList({ onEdit, onAddFirst, @@ -300,9 +69,9 @@ export function MeasurementList({ const { t } = useTranslations(); const fmt = useFormatters(); const { isAuthenticated, user } = useAuth(); - // v1.36.x — inside somebody else's record, only the add path survives: - // entering a reading is an admitted delegated write, editing and deleting - // one are not. The row controls are dropped rather than disabled. + // Inside somebody else's record only the add path survives: entering a + // reading is an admitted delegated write, editing and deleting one are not. + // The row controls are dropped rather than disabled. const { canWriteDomain, canManage, inSharedRecord, canWrite } = useRecordCapabilities(); const canAddMeasurement = canWriteDomain("measurements"); @@ -314,87 +83,16 @@ export function MeasurementList({ inSharedRecord && canWrite && activeRecord ? accountLabel(activeRecord) : null; - const unitDisplay = useUnitDisplay(); - // v1.32.26 — a single-reading row renders its value + unit in the user's - // preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the - // transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the - // type's canonical unit) is shown verbatim so its stored unit is never - // silently relabelled. Metric users take the identity path — unchanged. - // Glucose sits on its own display axis: stored mg/dL, rendered in whichever - // of mg/dL and mmol/L the account chose. It is not part of the - // metric/imperial registry, so it needs its own branch or a reader on - // mmol/L would see the dashboard tile and this list disagree about the - // same reading. - const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); - // An edit whose input is NOT in the stored unit. Only the mmol/L branch - // qualifies: an mg/dL account edits in the unit the column holds, and - // routing it through the converter anyway would quantise a stored 95.48 to - // 95 the moment somebody opened the sheet to fix a note. - const editsConvertedGlucose = useCallback( - (m: { type: string; unit: string }) => - m.type === "BLOOD_GLUCOSE" && - glucoseUnit === "mmol/L" && - m.unit === getUnitForType(m.type), - [glucoseUnit], - ); - const rowDisplay = useCallback( - (m: { type: string; value: number; unit: string }) => { - if (m.unit !== getUnitForType(m.type)) { - return { value: m.value, unit: m.unit }; - } - if (m.type === "BLOOD_GLUCOSE") { - return { - value: convertGlucose(m.value, glucoseUnit), - unit: glucoseUnit, - }; - } - return { - value: unitDisplay.toDisplay(m.type, m.value), - unit: unitDisplay.unitFor(m.type), - }; - }, - [unitDisplay, glucoseUnit], - ); - /** - * Machine-readable mirror of what the row renders — the same number and - * the same unit, before `Intl` turns them into locale text. - * - * The rendered cell is the only place a reading's value and unit appear - * together, and it appears there as "8.421 steps" or "8,421 steps" - * depending on who is looking. A browser test that has to assert a stored - * unit therefore either pins a locale or matches a loose number, and both - * describe the formatter rather than the reading. These attributes carry - * the reading itself, and the desktop table and the mobile list emit the - * same set, so an assertion holds at either viewport. - */ - const rowIdentityAttrs = useCallback( - (m: Measurement, isGrouped: boolean) => ({ - "data-testid": "measurement-row", - "data-measurement-type": m.type, - "data-measurement-value": String( - isGrouped ? m.value : rowDisplay(m).value, - ), - "data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit, - }), - [rowDisplay], - ); - // v1.28.42 — the desktop table and the mobile card list used to BOTH - // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 - // rows) or sleep page built every row subtree twice — double the DOM, double - // the per-row `Intl` work. Gate on the SSR-safe `useIsMobile` so only the - // active layout mounts. The hook resolves to the desktop branch on the server - // and the first client paint (matching the SSR HTML → no hydration - // mismatch), then flips to the live viewport after hydration; the `md:*` - // visibility classes below stay as a belt-and-suspenders visual guard for - // that first frame. No row markup or data-slot changes. + const display = useMeasurementRowDisplay(); + // Only the active layout mounts: a dense day-grouped page used to build + // every row twice, once per layout, with only CSS hiding one. The hook + // resolves to the desktop branch on the server and the first client paint + // (matching the SSR HTML), then follows the viewport; the `md:*` classes + // stay as a guard for that first frame. const isMobile = useIsMobile("md"); - const queryClient = useQueryClient(); const router = useRouter(); - // v1.22 — clicking a row's type badge drills into that metric's Insights - // sub-page when one exists, else into the filtered measurements list. The - // reverse map prefers the dedicated single-metric slug (PULSE → `pulse`, - // WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep - // link the page already supports. + // A row's type badge drills into that metric's Insights page when there is + // one, else into the list filtered to the type. const navigateForType = (type: string) => { const slug = subPageSlugForType(type); router.push( @@ -403,67 +101,11 @@ export function MeasurementList({ : `/measurements?type=${encodeURIComponent(type)}`, ); }; - // URL-owned filter state (the documents-vault pattern): type, source, date - // range and value range parse from `?type&source&from&to&min&max` and every - // change writes back through the router, so a filtered view survives - // navigation, reload and back/forward. The `?type=` deep links (Vorsorge - // card, type-badge drill-down) now simply ARE the filter state. - // v1.8.5 — when `lockedType` is set the list is pinned to that metric, the - // type selector is hidden, and the URL belongs to the insights subpage — - // the remaining filters stay in component memory (`syncToUrl: false`). - const { filters, applyFilters, patchFilters } = useUrlFilterSync({ - parse: parseMeasurementListSearchParams, - serialise: measurementListFiltersToSearch, - syncToUrl: lockedType === undefined, - }); - const typeFilter = lockedType ?? filters.type ?? "ALL"; - // v1.15.13 — management-list source filter + optional date range. - // `ALL` clears the source filter; empty date strings clear the bound. - const sourceFilter = filters.source ?? "ALL"; - const fromDay = filters.fromDay ?? ""; - const toDay = filters.toDay ?? ""; - // v1.18.5 — value-range filter (backlog G). The inputs fire per keystroke, - // so they edit a local draft that debounces into the URL (200 ms, replace — - // never a history entry per keystroke); an external URL change (back - // button, deep link) re-seeds the draft via the render-phase derived-state - // adjustment below, exactly like the vault's search field. - const urlValueRange = `${filters.valueMin ?? ""}\u0000${filters.valueMax ?? ""}`; - const [valueDraft, setValueDraft] = useState(() => ({ - min: filters.valueMin ?? "", - max: filters.valueMax ?? "", - })); - const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange); - if (urlValueRange !== lastUrlValueRange) { - setLastUrlValueRange(urlValueRange); - setValueDraft({ - min: filters.valueMin ?? "", - max: filters.valueMax ?? "", - }); - } - const valueMinInput = valueDraft.min; - const valueMaxInput = valueDraft.max; - const [page, setPage] = useState(1); - // Shared column-sort state (measuredAt opens descending). - const { - sortBy, - sortDir, - toggleSort: applySort, - } = useTableSort({ - defaultColumn: "measuredAt", - defaultDir: "desc", - descColumns: MEASUREMENT_DESC_COLUMNS, - }); + const filters = useMeasurementListFilters(lockedType); + const { typeFilter, page, clearSelection, goToPage, setTypeFilter } = filters; - // v1.15.13 — page-scoped multi-select. Holds the ids selected on the - // CURRENT page; cleared on any page / filter / sort change (per the - // v1.15.x audit — no "select across 200k rows"). The bulk-delete - // payload is intersected with the painted page ids, so it is always - // page-bounded (≤ PAGE_SIZE) and well under the server's 200 cap. - const [selectedIds, setSelectedIds] = useState>(() => new Set()); - - // v1.4.37 — 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. + // The day rows whose drill-down is open; each opens a one-shot read of the + // day's samples on first expand. const [expandedDayKeys, setExpandedDayKeys] = useState>( () => new Set(), ); @@ -476,455 +118,29 @@ export function MeasurementList({ }); } - const [editing, setEditing] = useState(null); - const [editValue, setEditValue] = useState(""); - const [editMeasuredAt, setEditMeasuredAt] = useState(""); - const [editNotes, setEditNotes] = useState(""); - const [editSeed, setEditSeed] = useState({ - value: "", - measuredAt: "", - notes: "", - }); - const [editError, setEditError] = useState(null); - const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); - // v1.4.27 — link the edit dialog error banner to the inputs via - // `aria-describedby` so screen readers announce the validation - // failure when the user submits an invalid value or timestamp. - const editErrorId = useId(); - const editErrorDescriptor = editError ? editErrorId : undefined; - // v1.4.27 — Sheet-branch sticky-pinned footer slot. - const editFormId = useId(); - const [editFooterEl, setEditFooterEl] = useState(null); - - // v1.15.13 — every filter / page / sort change resets pagination to - // page 1 AND clears the page-scoped selection (the rows it referred to - // are about to unmount). - const clearSelection = () => setSelectedIds(new Set()); - - // Discrete facet changes push (Back steps through filter states, like the - // vault's chips); the committed date bounds replace — a from/to pair set in - // one sitting should not cost two Back presses. - const setTypeFilter = (value: string) => { - patchFilters({ type: value === "ALL" ? undefined : value }); - setPage(1); - clearSelection(); - }; - - const setSourceFilter = (value: string) => { - patchFilters({ source: value === "ALL" ? undefined : value }); - setPage(1); - clearSelection(); - }; - - const setFromDay = (value: string) => { - patchFilters({ fromDay: value || undefined }, "replace"); - setPage(1); - clearSelection(); - }; - - const setToDay = (value: string) => { - patchFilters({ toDay: value || undefined }, "replace"); - setPage(1); - clearSelection(); - }; - - const setValueMinInput = (value: string) => { - setValueDraft((prev) => ({ ...prev, min: value })); - setPage(1); - clearSelection(); - }; - - const setValueMaxInput = (value: string) => { - setValueDraft((prev) => ({ ...prev, max: value })); - setPage(1); - clearSelection(); - }; - - // The value-range draft debounces into the URL; a bound that does not - // normalise to a finite number never reaches it (matching the previous - // in-memory behaviour, where non-numeric input meant "unset"). - useEffect(() => { - const handle = setTimeout(() => { - const min = numericInputOrUndefined(valueDraft.min); - const max = numericInputOrUndefined(valueDraft.max); - if ( - (min ?? "") === (filters.valueMin ?? "") && - (max ?? "") === (filters.valueMax ?? "") - ) { - return; - } - patchFilters({ valueMin: min, valueMax: max }, "replace"); - }, 200); - return () => clearTimeout(handle); - }, [valueDraft, filters, patchFilters]); - - const goToPage = (updater: (p: number) => number) => { - setPage(updater); - clearSelection(); - }; - - // Compose the page-reset + selection-clear side effects around the - // shared sort toggle (the rows the selection referred to are about to - // re-order and the list snaps back to page 1). - function toggleSort(column: string) { - applySort(column); - setPage(1); - clearSelection(); - } - - // v1.4.37 — 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); - // v1.11.5 — SLEEP_DURATION is collapsed server-side to one row per - // night (TIME ASLEEP), so the list paints per-night rows with a - // chevron drilling into the night's stage segments. Like the - // cumulative branch it pages server-side over its synthesised rows. - const isSleepFilter = typeFilter === "SLEEP_DURATION"; - const isDayGroupedFilter = isCumulativeFilter || isSleepFilter; - - // v1.15.13 — derive the ISO datetime window the list `from`/`to` - // params require from the two date inputs (local start/end of day). - const fromIso = dayBoundaryIso(fromDay, "start"); - const toIso = dayBoundaryIso(toDay, "end"); - const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter; - // v1.18.5 — value-range bounds. Empty / non-numeric input clears the - // bound. Only threaded on the raw per-sample list; the day-grouped and - // sleep-night modes synthesise rows server-side (a SUM / per-night - // TIME-ASLEEP), where filtering on a raw `value` column carries no - // meaning, so the range pill is hidden for those types. - const valueMin = - filters.valueMin !== undefined ? Number(filters.valueMin) : undefined; - const valueMax = - filters.valueMax !== undefined ? Number(filters.valueMax) : undefined; - const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter - ? "sleep-night" - : isCumulativeFilter - ? "groupBy=day" - : "raw"; - - const { data, isLoading, isError, error, refetch } = useQuery({ - queryKey: queryKeys.measurementsList({ - type: typeFilter === "ALL" ? undefined : typeFilter, - sourceEq, - from: fromIso, - to: toIso, - // Value range applies only to the raw per-sample list. - valueMin: isDayGroupedFilter ? undefined : valueMin, - valueMax: isDayGroupedFilter ? undefined : valueMax, - page, - sortBy, - sortDir, - mode: listMode, - }), - queryFn: async () => { - const params = new URLSearchParams(); - if (typeFilter !== "ALL") params.set("type", typeFilter); - if (sourceEq) params.set("sourceEq", sourceEq); - if (fromIso) params.set("from", fromIso); - if (toIso) params.set("to", toIso); - // v1.18.5 — value range only on the raw per-sample list. - if (!isDayGroupedFilter && valueMin != null) - params.set("valueMin", String(valueMin)); - if (!isDayGroupedFilter && valueMax != null) - params.set("valueMax", String(valueMax)); - params.set("limit", String(PAGE_SIZE)); - params.set("offset", String((page - 1) * PAGE_SIZE)); - params.set("sortBy", sortBy); - params.set("sortDir", sortDir); - // The day-grouped modes page over days, the same 25 per page as - // the per-sample list: `groupBy=day` folds each cumulative day to - // its sum, and SLEEP_DURATION collapses to one row per night - // without a param. - if (isCumulativeFilter) params.set("groupBy", "day"); - return apiGet<{ - measurements: Measurement[]; - meta: { total: number }; - }>(`/api/measurements?${params}`); - }, + const list = useMeasurementListData({ + filters, enabled: isAuthenticated, + lockedType, + onEmptyChange, }); - - const listEmpty = - !isLoading && - !isError && - !data?.measurements?.length && - (typeFilter === "ALL" || !!lockedType); - useEffect(() => { - onEmptyChange?.(listEmpty); - }, [listEmpty, onEmptyChange]); - - // v1.16.4 — deletes are soft (tombstones), so the success toast can - // carry a real Undo: it POSTs the ids to `/api/measurements/restore`, - // which clears `deletedAt` and re-fires the same dependent-key bundle. - // Mirrors the intake-Undo pattern in `use-medication-intake.ts`. - const restoreMeasurements = useCallback( - async (ids: string[]) => { - try { - await apiPost("/api/measurements/restore", { ids }); - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); - toast.success(t("measurements.restoredToast")); - } catch (failure) { - showErrorToast(t("measurements.restoreError"), { cause: failure }); - } - }, - [queryClient, t], - ); - - const deleteMutation = useMutation({ - mutationFn: async (id: string) => { - await apiDelete(`/api/measurements/${id}`); - }, - onSuccess: (_data, id) => { - void invalidateKeys(queryClient, measurementDependentKeys); - void refetchInactiveDailyReads(queryClient); - toast.success(t("measurements.deletedToast"), { - action: { - label: t("common.undo"), - onClick: () => void restoreMeasurements([id]), - }, - }); - }, - // v1.16.4 — a failed row delete used to fail silently (no onError - // handler); the edit-dialog path surfaced its own inline error but - // the row-level confirm had no feedback at all. - onError: (failure) => { - showErrorToast(t("measurements.deleteError"), { cause: failure }); - }, + const { + data, + isLoading, + isError, + error, + refetch, + selectedOnPage, + onConfirmBulkDelete, + bulkDeleteMutation, + totalPages, + } = list; + const edit = useMeasurementEdit({ + onEdit, + display, + deleteMutation: list.deleteMutation, }); - // v1.15.13 — page-scoped bulk soft-delete. Posts the selected ids - // (intersected with the painted page) to the bulk-delete route, then - // invalidates the same dependent-key bundle the single delete busts so - // the dashboard / insights / chart caches stay in lockstep. - const bulkDeleteMutation = useMutation({ - mutationFn: async (ids: string[]) => { - const data = await apiPost<{ deleted: number }>( - "/api/measurements/bulk-delete", - { ids }, - ); - return data.deleted; - }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); - clearSelection(); - toast.success( - deleted === 1 - ? t("measurements.bulkDeleteSuccessOne") - : t("measurements.bulkDeleteSuccess", { count: String(deleted) }), - { - action: { - label: t("common.undo"), - onClick: () => void restoreMeasurements(ids), - }, - }, - ); - }, - onError: (failure) => { - showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); - }, - }); - - const pageIds = (data?.measurements ?? []) - // Synthetic day-grouped / sleep-night rows aren't individually - // deletable (they collapse many raw rows behind a `dayKey`), so they - // are excluded from the selectable set. - .filter((m) => !(m.dayKey !== undefined && m.sampleCount !== undefined)) - .map((m) => m.id); - - // Selection is held as a raw id set but every read intersects it with - // the painted page (`selectAllState` / `selectedCountOnPage` / - // `selectedIdsOnPage`), so an id that falls off the page after a - // refetch (e.g. a single-row delete) simply stops counting — no stale - // selection survives a page / filter / sort change either, since those - // call `clearSelection()`. No prune effect is needed. - const selectAll = selectAllState(selectedIds, pageIds); - const selectedOnPage = selectedCountOnPage(selectedIds, pageIds); - - function onToggleRow(id: string) { - setSelectedIds((prev) => toggleId(prev, id)); - } - - function onToggleSelectAll() { - setSelectedIds((prev) => toggleSelectAll(prev, pageIds)); - } - - function onConfirmBulkDelete() { - const ids = selectedIdsOnPage(selectedIds, pageIds); - if (ids.length === 0) return; - bulkDeleteMutation.mutate(ids); - } - - const updateMutation = useMutation({ - mutationFn: async ({ - id, - value, - measuredAt, - notes, - }: { - id: string; - value: number; - measuredAt: string; - notes: string | null; - }) => { - await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes }); - }, - onSuccess: async () => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); - setEditSeed({ - value: editValue, - measuredAt: editMeasuredAt, - notes: editNotes, - }); - dismissEdit(); - }, - onError: (err) => { - // v1.4.28 — the PUT route returns a 409 with - // `meta.errorCode === "measurement.duplicate_timestamp"` when the - // edit collides with an existing row's - // `(type, measuredAt, source, sleepStage)` tuple. ApiError carries - // the envelope `meta`, so the localised string can be picked here. - if ( - err instanceof ApiError && - err.meta?.errorCode === "measurement.duplicate_timestamp" - ) { - setEditError(t("measurements.duplicateTimestamp")); - return; - } - // v1.27.5 (di-001) — value edits on server-owned sources - // (connector / import / computed rows) are refused with a 409. - if ( - err instanceof ApiError && - err.meta?.errorCode === "measurement.update.server_owned_source" - ) { - setEditError(t("measurements.serverOwnedSource")); - return; - } - setEditError( - err instanceof Error ? err.message : t("measurements.saveError"), - ); - }, - }); - const editDismissal = useSeededFormDismissal({ - seed: editSeed, - value: { - value: editValue, - measuredAt: editMeasuredAt, - notes: editNotes, - }, - blocked: updateMutation.isPending || deleteMutation.isPending, - }); - - // Every mode pages server-side at PAGE_SIZE: the per-sample list over - // rows, the day-grouped modes over days or nights (#1245). - const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; - - function startEdit(measurement: Measurement) { - if (onEdit) { - onEdit(measurement); - return; - } - - // Seed the input in the user's preferred unit so an imperial user edits in - // lb / in / °F (converted back to canonical on save). Metric users and - // legacy non-canonical rows take the identity path — the seed is the raw - // stored value, unchanged. Round the converted imperial seed so the input - // shows "210" rather than a long float. - const rd = rowDisplay(measurement); - const seedValue = editsConvertedGlucose(measurement) - ? rd.value - : unitDisplay.preference === "imperial" && - unitDisplay.isTransformed(measurement.type) && - measurement.unit === getUnitForType(measurement.type) - ? Math.round(rd.value * 100) / 100 - : measurement.value; - const seed = { - value: String(seedValue), - measuredAt: toDateTimeLocalValue(measurement.measuredAt), - notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH), - }; - setEditing(measurement); - setEditValue(seed.value); - setEditMeasuredAt(seed.measuredAt); - setEditNotes(seed.notes); - setEditSeed(seed); - setEditError(null); - } - - function dismissEdit() { - setEditing(null); - setEditError(null); - setEditDeleteDialogOpen(false); - } - - function closeEdit() { - editDismissal.requestClose(dismissEdit); - } - - async function deleteEditingMeasurement() { - if (!editing) return; - - try { - setEditError(null); - await deleteMutation.mutateAsync(editing.id); - dismissEdit(); - } catch { - setEditError(t("measurements.deleteError")); - } - } - - async function submitEdit(e: React.FormEvent) { - e.preventDefault(); - if (!editing) return; - - const parsedValue = Number(editValue); - if (!Number.isFinite(parsedValue)) { - setEditError(t("measurements.invalidValue")); - return; - } - - const measuredDate = new Date(editMeasuredAt); - if (Number.isNaN(measuredDate.getTime())) { - setEditError(t("measurements.invalidTimestamp")); - return; - } - - // The input holds a display-unit value; invert it to canonical SI before - // the PATCH so an imperial edit persists in the stored unit. Metric users - // and legacy non-canonical rows are the identity path — value unchanged. - let canonicalValue = parsedValue; - if (editsConvertedGlucose(editing)) { - // The label above the input said mmol/L, so the number in it is - // mmol/L. Sending it on would rewrite a 95 mg/dL reading as 5.3. - canonicalValue = toCanonicalMgdl(parsedValue, "mmol/L"); - } else if ( - unitDisplay.isTransformed(editing.type) && - editing.unit === getUnitForType(editing.type) - ) { - const inverted = unitDisplay.fromDisplay(editing.type, parsedValue); - canonicalValue = - unitDisplay.preference === "imperial" - ? Math.round(inverted * 100) / 100 - : inverted; - } - - setEditError(null); - updateMutation.mutate({ - id: editing.id, - value: canonicalValue, - measuredAt: measuredDate.toISOString(), - notes: editNotes.trim() ? editNotes.trim() : null, - }); - } - if (!isAuthenticated) { return (

@@ -933,17 +149,22 @@ export function MeasurementList({ ); } + const rowProps = { + canManage, + filters, + list, + display, + expandedDayKeys, + toggleExpand, + navigateForType, + startEdit: (m: Measurement) => edit.startEdit(m), + }; + return ( <>

- {/* v1.16.1 — unified filter rail (``): compact pill - triggers in the canonical order date range · type · source, - active filters fold into their pill as removable chips, plus - a reset action and the result count. Filter state and query - keys are unchanged — this swap is presentation-only. - On the locked-type insights subpage the rail is suppressed - entirely (the metric is fixed by the route); only the count - line remains. */} + {/* The filter rail; on the locked-type page (the metric is fixed by + the route) only the count line remains. */} {lockedType ? ( data?.meta?.total !== undefined && (
@@ -955,76 +176,7 @@ export function MeasurementList({
) ) : ( - { - // One push clears every facet (and so one Back restores them); - // the value-range draft is cleared directly because a bound - // that never committed to the URL has no change to re-seed - // from. - applyFilters({}); - setValueDraft({ min: "", max: "" }); - setPage(1); - clearSelection(); - }} - count={ - data?.meta?.total !== undefined - ? t("measurements.measurementCount", { - count: fmt.integer(data.meta.total), - }) - : undefined - } - > - - ({ value: val, label: t(labelKey) }), - )} - /> - ({ - value: src, - label: formatMeasurementSource(src, t), - }))} - /> - {/* v1.18.5 — value-range pill. Hidden for the day-grouped / - sleep-night types where the painted `value` is a synthesised - SUM / per-night total rather than a raw reading. */} - {!isDayGroupedFilter && ( - - )} - + )} {isLoading ? ( @@ -1038,597 +190,31 @@ export function MeasurementList({ // an outage never reads as "you have no measurements". void refetch()} /> ) : !data?.measurements?.length ? ( - <> - {/* v1.4.15: replaces the bare-text empty rectangle. - Filter-aware copy distinguishes brand-new accounts ("no - measurements yet") from filtered-out lists ("no measurements - match this filter") and exposes the right CTA for each case. */} - } - title={ - typeFilter === "ALL" || lockedType - ? t("measurements.emptyTitle") - : t("measurements.emptyFilteredTitle") - } - description={ - typeFilter === "ALL" || lockedType - ? t("measurements.emptyDescription") - : t("measurements.emptyFilteredDescription") - } - action={ - // v1.8.5 — a locked-type list has no "reset filter" path; - // the metric is fixed by the route. - typeFilter !== "ALL" && !lockedType ? ( - - ) : onAddFirst && canAddMeasurement ? ( - - ) : undefined - } - /> - {/* The one place in the app the asymmetry is spelled out. A - delegate who may add will look for the edit control beside the - row they just entered, so the list that carries the add button - says once, in one muted line, where the limit is. Every other - hidden control stays silent: the banner already names the mode, - and a chip beside each absent button would be noise. */} - {delegateName && ( -

- {t("recordSharing.delegate.addOnlyNote", { - name: delegateName, - })} -

- )} - + setTypeFilter("ALL")} + onAddFirst={canAddMeasurement ? onAddFirst : undefined} + delegateName={delegateName} + /> ) : ( <> - {/* Desktop table — rendered only when not mobile (v1.28.42 H3). */} + {/* Desktop table or phone list, whichever layout is active. */} {!isMobile && ( -
- - - - - {/* v1.15.13 — select-all-on-page header checkbox. - v1.36.x — bulk delete is the owner's alone, so a - delegate gets no selection column. */} - {canManage && ( - - )} - - - {t("measurements.type")} - - - - - {t("measurements.comment")} - - - - {t("measurements.actions")} - - - - - {data.measurements.map((m) => { - const isGrouped = - m.dayKey !== undefined && m.sampleCount !== undefined; - // v1.11.5 — sleep night rows are grouped (chevron - // drills into the stage segments) but render a - // sleep-aware headline + nap caption, not the - // cumulative "daily total" caption. - const isSleep = m.type === "SLEEP_DURATION"; - const isExpanded = isGrouped - ? expandedDayKeys.has(m.dayKey as string) - : false; - // v1.4.38 — stable drill-down id so the - // disclosure chevron can thread aria-controls to the - // expanded panel. dayKey is unique per row when - // grouped; fall back to m.id otherwise. - const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`; - const isSelected = selectedIds.has(m.id); - return ( - - - - {/* Grouped/synthetic rows aren't individually - deletable, so they have no checkbox. */} - {!isGrouped && canManage && ( - onToggleRow(m.id)} - aria-label={t("dataList.selectRow")} - /> - )} - - - - - - - - {/* v1.4.39.3 — non-grouped rows render the - stored value with its native precision - (`fmt.number` honours up to 3 fraction - digits by default, drops trailing zeros) - so a 78.4 kg weight reading stops - truncating to "78". Grouped rows are - cumulative-type day aggregates (steps, - distance, etc.) and stay on `fmt.integer` - because the underlying readings are - integer-only by definition. - v1.11.5 — sleep rows render the night's - TIME ASLEEP as "8h 12m" + a nap caption. */} - {isSleep ? ( - <> - {formatDurationMinutes(m.value, t)} - - - ) : ( - <> - {isGrouped - ? fmt.integer(m.value) - : fmt.number( - rowDisplay(m).value, - rawDisplayFractionDigits(m.type), - )}{" "} - {measurementUnitLabel( - m.type, - isGrouped ? m.unit : rowDisplay(m).unit, - t, - )} - {isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer( - m.sampleCount as number, - ), - })} - - )} - - )} - - {/* - v1.4.43 — `formatDateOrRelative` - renders timestamps inside the last 24 h - as "vor 3 min" so a fresh entry visible - on the dashboard briefing and on the - list view never disagrees about how to - phrase "when". - */} - - {/* v1.42 — the date opens its day; the row - keeps opening the reading. */} - {m.dayKey !== undefined ? ( - - {formatDateOrRelative(m.measuredAt, t)} - - ) : ( - - {formatDateOrRelative(m.measuredAt, t)} - - )} - - - {m.notes ? ( - - {truncateComment(m.notes)} - - ) : ( - - - )} - - - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} - - -
- {isGrouped ? ( - - ) : ( - <> - {canManage && ( - - )} - - deleteMutation.mutate(m.id) - } - title={t( - "measurements.deleteConfirmTitle", - )} - description={t( - "measurements.deleteConfirmDescription", - )} - /> - - )} -
-
-
- {isGrouped && isExpanded && ( - - - - - - )} -
- ); - })} -
-
-
+ )} - - {/* Mobile list — rendered only when mobile (v1.28.42 H3). */} {isMobile && ( -
- {data.measurements.map((m) => { - const Icon = TYPE_ICONS[m.type]; - const isGrouped = - m.dayKey !== undefined && m.sampleCount !== undefined; - const isSleep = m.type === "SLEEP_DURATION"; - const isExpanded = isGrouped - ? expandedDayKeys.has(m.dayKey as string) - : false; - // v1.4.38 — see desktop counterpart. - const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`; - const isSelected = selectedIds.has(m.id); - return ( - -
-
- {/* v1.15.13 — multi-select checkbox in a 44px tap - target; absent for synthetic grouped rows. */} - {!isGrouped && canManage && ( - // v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox - // (itself a ` - )} -
- {/* v1.22 — metadata badges sit on the scale - token `text-xs` (12 px, the mobile legibility - baseline) instead of an arbitrary per-site - size, so the row tracks the type scale. */} - {/* The type names the row: without it two - readings in the same unit ("55 ms" HRV and - "63 ms" RMSSD) could not be told apart on a - phone, where the desktop table's type column - does not exist. */} -

- {TYPE_LABEL_KEYS[m.type] - ? t(TYPE_LABEL_KEYS[m.type]) - : m.type} -

- - {/* v1.4.39.3 — mirror the desktop table: - grouped daily aggregates stay integer, - non-grouped single readings render with - their native decimal precision so - "78.4 kg" no longer truncates to "78". - v1.11.5 — sleep rows render TIME ASLEEP. */} - {isSleep - ? formatDurationMinutes(m.value, t) - : isGrouped - ? fmt.integer(m.value) - : fmt.number( - rowDisplay(m).value, - rawDisplayFractionDigits(m.type), - )} - - {isSleep ? ( - - ) : ( - isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer(m.sampleCount as number), - })} - - ) - )} - {/* The meta line wraps rather than truncates: - cut to "vor 9 Stunden…" it hid the day it - opens and the source beside it. */} -

- {/* - v1.4.43 — see desktop - counterpart at the same `measuredAt` - site. Relative under 24 h, absolute - older. - */} - {/* The unit is meta, and on a phone a long - one ("Atemzüge/min") beside the value pushed - the row's controls; it leads the meta line - instead. Sleep reads as a duration. */} - {(() => { - if (isSleep) return null; - const lead = measurementUnitMetaLead( - m.type, - isGrouped ? m.unit : rowDisplay(m).unit, - t, - ); - return lead ? ( - - {lead} - - ) : null; - })()} - {m.dayKey !== undefined ? ( - - {formatDateOrRelative(m.measuredAt, t)} - - ) : ( - - {formatDateOrRelative(m.measuredAt, t)} - - )} - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} -

- {m.notes && ( - // User data is never muted — the desktop table - // renders the same note in the foreground colour. -

- {truncateComment(m.notes)} -

- )} -
-
-
- {isGrouped ? ( - - ) : ( - <> - {canManage && ( - - )} - deleteMutation.mutate(m.id)} - title={t("measurements.deleteConfirmTitle")} - description={t( - "measurements.deleteConfirmDescription", - )} - /> - - )} -
-
- {isGrouped && isExpanded && ( -
- -
- )} -
- ); - })} -
+ )} )} - {/* v1.15.13 — page-scoped multi-select action bar. */} + {/* Page-scoped multi-select action bar. */} - {/* Pagination */} {totalPages > 1 && ( -
- - {t("measurements.pageInfo", { - page: String(page), - total: String(totalPages), - })} - -
- - -
-
+ )}
- !open && closeEdit()} - title={t("measurements.editMeasurement")} - footer={
} - > - {editing && ( -
-
- - - {TYPE_LABEL_KEYS[editing.type] - ? t(TYPE_LABEL_KEYS[editing.type]) - : editing.type} - -
- -
- - setEditValue(e.target.value)} - required - aria-required="true" - aria-invalid={!!editError || undefined} - aria-describedby={editErrorDescriptor} - /> -
- -
- - -
- -
-
- - - {editNotes.length}/{MAX_COMMENT_LENGTH} - -
- setEditNotes(e.target.value)} - maxLength={MAX_COMMENT_LENGTH} - enterKeyHint="done" - autoCapitalize="sentences" - /> -
- - {editError && ( - - )} - - {editFooterEl - ? createPortal( -
- - - - - - setEditDeleteDialogOpen(true)} - > - - {t("common.delete")} - - - - - {/* - v1.4.43 — `[Cancel] [Save]` order is - iOS-first intentional per Apple HIG, which - puts the primary / confirmation action - rightmost. iOS is the load-bearing mobile - target for HealthLog and the web shell renders - desktop + iOS PWA from the same component, so - the current order survives. Do NOT flip to - `[Save] [Cancel]` in a future "Android parity" - refactor — fork a platform-specific shell - instead if Android becomes a real audience. - */} -
- - -
-
, - editFooterEl, - ) - : null} - - - - - - {t("measurements.deleteConfirmTitle")} - - - {t("measurements.deleteConfirmDescription")} - - - - - {t("common.cancel")} - - - {deleteMutation.isPending ? ( - - ) : null} - {t("common.delete")} - - - - -
- )} - - + ); } /** - * v1.4.37 — 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. + * Nothing to list. The copy tells a new account ("no measurements yet") from + * a filtered-out list, with the action each one needs; a locked-type list has + * no filter to reset, since the route fixes the metric. */ -function DayDrillDown({ - type, - dayKey, - unit, - layout, +function MeasurementListEmpty({ + filtered, + onResetFilter, + onAddFirst, + delegateName, }: { - type: string; - dayKey: string; - unit: string; - layout: "desktop" | "mobile"; + filtered: boolean; + onResetFilter: () => void; + onAddFirst?: () => void; + delegateName: string | null; }) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); - const { data, isLoading, error, refetch } = useQuery({ - queryKey: queryKeys.measurementDrilldown(type, dayKey), - queryFn: async () => { - const params = new URLSearchParams(); - params.set("type", type); - params.set("dayKey", dayKey); - params.set("sortDir", "asc"); - return apiGet<{ measurements: Measurement[] }>( - `/api/measurements?${params}`, - ); - }, - 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 ( - void refetch()} - className={layout === "desktop" ? "m-2" : "mt-2"} - /> - ); - } - const rows = data?.measurements ?? []; - if (rows.length === 0) { - return ( - - ); - } - // v1.11.5 — for SLEEP_DURATION the drill-down rows are stage segments; - // render the stage label + minutes instead of a bare value + unit. - const isSleep = type === "SLEEP_DURATION"; - if (layout === "desktop") { - return ( -
- {rows.map((s) => ( -
- - {isSleep - ? sleepStageLabel(s.sleepStage, t) - : formatDateTime(s.measuredAt)} - - - {isSleep - ? formatDurationMinutes(s.value, t) - : `${s.value} ${unit}`} - -
- ))} -
- ); - } return ( -
- {rows.map((s) => ( -
+ } + title={ + filtered + ? t("measurements.emptyFilteredTitle") + : t("measurements.emptyTitle") + } + description={ + filtered + ? t("measurements.emptyFilteredDescription") + : t("measurements.emptyDescription") + } + action={ + filtered ? ( + + ) : onAddFirst ? ( + + ) : undefined + } + /> + {/* The one place in the app the asymmetry is spelled out. A delegate + who may add will look for the edit control beside the row they just + entered, so the list that carries the add button says once, in one + muted line, where the limit is. Every other hidden control stays + silent: the banner already names the mode. */} + {delegateName && ( +

- - {isSleep - ? sleepStageLabel(s.sleepStage, t) - : formatDateTime(s.measuredAt)} - - - {isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`} - -

- ))} + {t("recordSharing.delegate.addOnlyNote", { + name: delegateName, + })} +

+ )} + + ); +} + +function MeasurementListPagination({ + page, + totalPages, + goToPage, +}: { + page: number; + totalPages: number; + goToPage: (updater: (p: number) => number) => void; +}) { + const { t } = useTranslations(); + return ( +
+ + {t("measurements.pageInfo", { + page: String(page), + total: String(totalPages), + })} + +
+ + +
); } - -/** - * Sub-caption for a per-night sleep row: the row's value is the day's sleep, - * main night plus naps, so the caption says how many naps it includes, and - * the night's mid-sleep awakenings when present. - */ -function SleepNightCaption({ m }: { m: Measurement }) { - const { t } = useTranslations(); - const napCount = m.napCount ?? 0; - const awakenings = m.awakenings ?? 0; - const parts: string[] = []; - if (napCount > 0) { - parts.push(t("measurements.sleepNapCaption", { count: String(napCount) })); - } - if (awakenings > 0) { - parts.push( - t("measurements.sleepAwakeningsCaption", { count: String(awakenings) }), - ); - } - if (parts.length === 0) return null; - return ( - - {parts.join(" · ")} - - ); -}