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(" · ")} - - ); -}