refactor(measurements): split the measurement list into view state, data, rows and sheet

MeasurementList was a 1,586-line component with fourteen pieces of
state. Its parts move to components/measurements/list/:

- list-model: the row type, page size, labels and formatting helpers
- use-measurement-list-filters: URL-owned filters, the value-range
  draft, page, sort and the page-scoped selection
- use-measurement-list-data: the list read, the soft deletes with undo
  and the selection operations
- use-measurement-edit: the edit draft, its unit conversion both ways,
  the save and the discard guard
- use-measurement-row-display: how a row shows its reading, as a pure
  rule (rowDisplayFor) bound to the account
- measurement-filter-rail, measurement-table (one row component),
  measurement-card-list (one card component), row-actions shared by
  both layouts, measurement-edit-sheet, day-drill-down

measurement-list.tsx lays these out in about 330 lines. Markup and
behaviour are unchanged. The glucose edit checks call the conversion
functions instead of matching source, and the guards that named the
old file name the module that now holds what they check.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:42:43 +02:00
1 parent 5075536f7f
commit 3c0063edb2
21 files changed
+2389 -1887

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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"],
},
@@ -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,
},
@@ -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",
@@ -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("<SeededFormDiscardDialog");
});
it.each(["mood/mood-list.tsx", "labs/lab-history-list.tsx"])(
"routes every %s edit exit through the shared contract",
(relativePath) => {
const source = readFileSync(
@@ -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);
});
});
@@ -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 (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
</div>
);
}
if (error) {
return (
<QueryErrorRow
error={error}
slot="measurement-drilldown-error"
message={t("measurements.loadError")}
onRetry={() => void refetch()}
className={layout === "desktop" ? "m-2" : "mt-2"}
/>
);
}
const rows = data?.measurements ?? [];
if (rows.length === 0) {
return (
<EmptyState
variant="plain"
size="compact"
title={t("measurements.emptyDescription")}
/>
);
}
// 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 (
<div className="bg-muted/40 space-y-1 p-2">
{rows.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-3 px-2 py-1 text-xs"
>
<span className="text-muted-foreground tabular-nums">
{isSleep
? sleepStageLabel(s.sleepStage, t)
: formatDateTime(s.measuredAt)}
</span>
<span className="font-medium tabular-nums">
{isSleep
? formatDurationMinutes(s.value, t)
: `${s.value} ${unit}`}
</span>
</div>
))}
</div>
);
}
return (
<div className="bg-muted/40 mt-2 space-y-1 rounded-md p-2">
{rows.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-2 px-1 py-1 text-xs"
>
<span className="text-muted-foreground tabular-nums">
{isSleep
? sleepStageLabel(s.sleepStage, t)
: formatDateTime(s.measuredAt)}
</span>
<span className="font-medium tabular-nums">
{isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`}
</span>
</div>
))}
</div>
);
}
/**
* 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 (
<span className="text-muted-foreground ml-2 text-xs font-normal">
{parts.join(" · ")}
</span>
);
}
@@ -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<typeof useTranslations>["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<string>(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<string> = 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 "";
}
@@ -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<string>;
toggleExpand: (dayKey: string) => void;
navigateForType: (type: string) => void;
startEdit: (m: Measurement) => void;
}) {
const { selectedIds } = filters;
const { onToggleRow, deleteMutation } = list;
return (
<div data-slot="measurement-rows" className="space-y-2 md:hidden">
{measurements.map((m) => (
<MeasurementCard
key={m.id}
m={m}
canManage={canManage}
isSelected={selectedIds.has(m.id)}
isExpanded={
isGroupedRow(m) ? expandedDayKeys.has(m.dayKey as string) : false
}
onToggleRow={onToggleRow}
onDelete={(id) => deleteMutation.mutate(id)}
display={display}
toggleExpand={toggleExpand}
navigateForType={navigateForType}
startEdit={startEdit}
/>
))}
</div>
);
}
/** 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 (
<ListRow
data-state={isSelected ? "selected" : undefined}
// The same addressing contract as the desktop row.
{...rowIdentityAttrs(m, isGrouped)}
data-display-unit={isGrouped ? m.unit : rowDisplay(m).unit}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{!isGrouped && canManage && (
// The checkbox owns its 44 px hit area through an `after`
// hit-slop inside a plain layout `<div>`: a wrapper `<button>`
// would nest a button in a button, invalid markup that failed
// hydration on every list paint.
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</div>
)}
{Icon && (
<button
type="button"
onClick={() => navigateForType(m.type)}
aria-label={t("measurements.openMetricInsights", {
type: label,
})}
className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`}
>
<Icon className="h-4 w-4" />
</button>
)}
<div className="min-w-0 flex-1">
{/* 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. */}
<p
className="truncate text-xs font-medium"
data-slot="measurement-row-type"
>
{label}
</p>
<span
className="font-semibold tabular-nums"
data-slot="measurement-row-value"
>
{isSleep
? formatDurationMinutes(m.value, t)
: isGrouped
? fmt.integer(m.value)
: fmt.number(
rowDisplay(m).value,
rawDisplayFractionDigits(m.type),
)}
</span>
{isSleep ? (
<SleepNightCaption m={m} />
) : (
isGrouped && (
<span className="text-muted-foreground ml-1.5 text-xs">
{t("measurements.dailyTotalCaption", {
count: fmt.integer(m.sampleCount as number),
})}
</span>
)
)}
{/* The meta line wraps rather than truncates: cut short it hid
the day it opens and the source beside it. */}
<p className="text-muted-foreground text-xs">
{unitLead ? (
<span
data-slot="measurement-row-unit"
className="whitespace-nowrap"
>
{unitLead}
</span>
) : null}
{m.dayKey !== undefined ? (
<DayLink date={m.dayKey} className="whitespace-nowrap">
{formatDateOrRelative(m.measuredAt, t)}
</DayLink>
) : (
<DayLinkAt at={m.measuredAt} className="whitespace-nowrap">
{formatDateOrRelative(m.measuredAt, t)}
</DayLinkAt>
)}
{m.source !== "MANUAL" && (
<Badge
variant="outline"
data-testid="measurement-source-badge"
className={`ml-1.5 h-4 px-1 text-xs ${sourceBadgeClass(m.source)}`.trim()}
>
{formatMeasurementSource(m.source, t)}
</Badge>
)}
</p>
{m.notes && (
// User data is never muted; the table renders the same note in
// the foreground colour.
<p className="text-foreground truncate text-xs">
{truncateComment(m.notes)}
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<MeasurementRowActions
m={m}
isGrouped={isGrouped}
isExpanded={isExpanded}
drilldownId={drilldownId}
canManage={canManage}
layout="mobile"
toggleExpand={toggleExpand}
startEdit={startEdit}
onDelete={onDelete}
/>
</div>
</div>
{isGrouped && isExpanded && (
<div id={drilldownId}>
<DayDrillDown
type={m.type}
dayKey={m.dayKey as string}
unit={m.unit}
layout="mobile"
/>
</div>
)}
</ListRow>
);
}
@@ -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 (
<>
<ResponsiveSheet
open={!!editing}
onOpenChange={(open) => !open && closeEdit()}
title={t("measurements.editMeasurement")}
footer={<div ref={setEditFooterEl} className="flex w-full" />}
>
{editing && (
<form id={editFormId} onSubmit={submitEdit} className="space-y-4">
<div className="flex items-center gap-1.5">
<Label className="shrink-0">{t("measurements.type")}</Label>
<span className="text-sm leading-none font-medium">
{TYPE_LABEL_KEYS[editing.type]
? t(TYPE_LABEL_KEYS[editing.type])
: editing.type}
</span>
</div>
<div className="space-y-2">
<Label htmlFor="edit-value">
{t("measurements.valueWithUnit", {
unit: rowDisplay(editing).unit,
})}
</Label>
<Input
id="edit-value"
type="number"
enterKeyHint="next"
step="any"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-measuredAt">
{t("measurements.timestamp")}
</Label>
<DateTimeField
id="edit-measuredAt"
value={editMeasuredAt}
onChange={setEditMeasuredAt}
// Match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="edit-notes">
{t("measurements.notes")} ({t("common.optional")})
</Label>
<span className="text-muted-foreground text-xs">
{editNotes.length}/{MAX_COMMENT_LENGTH}
</span>
</div>
<Input
id="edit-notes"
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
maxLength={MAX_COMMENT_LENGTH}
enterKeyHint="done"
autoCapitalize="sentences"
/>
</div>
{editError && (
<div
id={editErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
{editFooterEl
? createPortal(<EditSheetFooter edit={edit} />, editFooterEl)
: null}
<AlertDialog
open={editDeleteDialogOpen}
onOpenChange={setEditDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurements.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurements.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={deleteEditingMeasurement}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
)}
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
onConfirm={editDismissal.confirmDiscard}
onCancel={editDismissal.cancelDiscard}
/>
</>
);
}
/**
* 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 (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-lg"
disabled={updateMutation.isPending || deleteMutation.isPending}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEditDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* `[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. */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={closeEdit}
disabled={updateMutation.isPending || deleteMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={editFormId}
disabled={updateMutation.isPending || deleteMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -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 (
<FilterBar
isFiltered={
typeFilter !== "ALL" ||
sourceFilter !== "ALL" ||
fromDay !== "" ||
toDay !== "" ||
(!isDayGroupedFilter && (valueMinInput !== "" || valueMaxInput !== ""))
}
onReset={resetFilters}
count={
total !== undefined
? t("measurements.measurementCount", {
count: fmt.integer(total),
})
: undefined
}
>
<FilterBarDateRange
label={t("dataList.dateRange")}
from={fromDay}
to={toDay}
onFromChange={setFromDay}
onToChange={setToDay}
idPrefix="measurements"
/>
<FilterBarSelect
label={t("dataList.typeLabel")}
value={typeFilter}
onValueChange={setTypeFilter}
allLabel={t("measurements.allTypes")}
options={Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => ({
value: val,
label: t(labelKey),
}))}
/>
<FilterBarSelect
label={t("dataList.sourceLabel")}
value={sourceFilter}
onValueChange={setSourceFilter}
allLabel={t("dataList.allSources")}
options={MEASUREMENT_SOURCE_OPTIONS.map((src) => ({
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 && (
<FilterBarNumberRange
label={t("dataList.valueRange")}
min={valueMinInput}
max={valueMaxInput}
onMinChange={setValueMinInput}
onMaxChange={setValueMaxInput}
idPrefix="measurements-value"
fromLabel={t("dataList.valueMin")}
toLabel={t("dataList.valueMax")}
/>
)}
</FilterBar>
);
}
@@ -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<string>;
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 (
<div
// `measurement-rows` marks the READ rows, not the list frame:
// the filter rail above renders next to the loading
// silhouettes as well, so it proves nothing about the data
// having arrived. This wrapper exists only in the resolved,
// non-empty branch, and the mobile list below carries the
// same marker so a wait on it holds at any viewport.
data-slot="measurement-rows"
className="bg-card border-border hidden overflow-hidden rounded-lg border md:block"
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-4">
{/* Select all on the page. Bulk delete is the owner's alone,
so a delegate gets no selection column. */}
{canManage && (
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
)}
</TableHead>
<TableHead className="w-28">{t("measurements.type")}</TableHead>
<SortableHead
column="value"
label={t("measurements.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="measuredAt"
label={t("measurements.date")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-56">{t("measurements.comment")}</TableHead>
<SortableHead
column="source"
label={t("measurements.source")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
className="w-20"
/>
<TableHead className="w-20 pr-4 text-right">
{t("measurements.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{measurements.map((m) => (
<MeasurementTableRow
key={m.id}
m={m}
canManage={canManage}
isSelected={selectedIds.has(m.id)}
isExpanded={
isGroupedRow(m)
? expandedDayKeys.has(m.dayKey as string)
: false
}
onToggleRow={onToggleRow}
onDelete={(id) => deleteMutation.mutate(id)}
display={display}
toggleExpand={toggleExpand}
navigateForType={navigateForType}
startEdit={startEdit}
/>
))}
</TableBody>
</Table>
</div>
);
}
/** 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 (
<>
<TableRow
data-state={isSelected ? "selected" : undefined}
// A row is addressable by what it holds, not by the copy it paints:
// the type is an enum and the unit a symbol, so a browser journey can
// pin "this glucose reading now reads in mmol/L" without matching
// translated text.
{...rowIdentityAttrs(m, isGrouped)}
data-display-unit={isGrouped ? m.unit : rowDisplay(m).unit}
>
<TableCell className="pl-4">
{/* A grouped row is not individually deletable: no checkbox. */}
{!isGrouped && canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
aria-label={t("dataList.selectRow")}
/>
)}
</TableCell>
<TableCell>
<Badge
asChild
variant="secondary"
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
>
<button
type="button"
onClick={() => navigateForType(m.type)}
aria-label={t("measurements.openMetricInsights", {
type: label,
})}
>
{label}
</button>
</Badge>
</TableCell>
<TableCell
className="font-semibold tabular-nums"
data-slot="measurement-row-value"
>
{/* 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)}
<SleepNightCaption m={m} />
</>
) : (
<>
{isGrouped
? fmt.integer(m.value)
: fmt.number(
rowDisplay(m).value,
rawDisplayFractionDigits(m.type),
)}{" "}
{measurementUnitLabel(
m.type,
isGrouped ? m.unit : rowDisplay(m).unit,
t,
)}
{isGrouped && (
<span className="text-muted-foreground ml-2 text-xs font-normal">
{t("measurements.dailyTotalCaption", {
count: fmt.integer(m.sampleCount as number),
})}
</span>
)}
</>
)}
</TableCell>
{/* 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. */}
<TableCell className="text-muted-foreground text-sm">
{m.dayKey !== undefined ? (
<DayLink date={m.dayKey}>
{formatDateOrRelative(m.measuredAt, t)}
</DayLink>
) : (
<DayLinkAt at={m.measuredAt}>
{formatDateOrRelative(m.measuredAt, t)}
</DayLinkAt>
)}
</TableCell>
<TableCell className="text-sm">
{m.notes ? (
<span title={m.notes}>{truncateComment(m.notes)}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{m.source !== "MANUAL" && (
<Badge
variant="outline"
data-testid="measurement-source-badge"
className={`text-xs ${sourceBadgeClass(m.source)}`.trim()}
>
{formatMeasurementSource(m.source, t)}
</Badge>
)}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<MeasurementRowActions
m={m}
isGrouped={isGrouped}
isExpanded={isExpanded}
drilldownId={drilldownId}
canManage={canManage}
layout="desktop"
toggleExpand={toggleExpand}
startEdit={startEdit}
onDelete={onDelete}
/>
</div>
</TableCell>
</TableRow>
{isGrouped && isExpanded && (
<TableRow id={drilldownId}>
<TableCell colSpan={7} className="p-0">
<DayDrillDown
type={m.type}
dayKey={m.dayKey as string}
unit={m.unit}
layout="desktop"
/>
</TableCell>
</TableRow>
)}
</>
);
}
@@ -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<typeof useTranslations>["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 ? (
<Button
variant="ghost"
size={size}
className={className}
data-testid="measurement-day-expand"
onClick={() => toggleExpand(m.dayKey as string)}
aria-expanded={isExpanded}
aria-controls={drilldownId}
aria-label={
isExpanded ? t("measurements.collapseDay") : t("measurements.expandDay")
}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</Button>
) : (
<>
{canManage && (
<Button
variant="ghost"
size={size}
className={className}
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className={desktop ? "h-3.5 w-3.5" : "h-4 w-4"} />
</Button>
)}
<DeleteButton
domain="measurements"
iconClassName={desktop ? undefined : "h-4 w-4"}
onConfirm={() => onDelete(m.id)}
title={t("measurements.deleteConfirmTitle")}
description={t("measurements.deleteConfirmDescription")}
/>
</>
);
}
@@ -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<MeasurementRowDisplay, "unitDisplay" | "editsConvertedGlucose">,
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<Measurement | null>(null);
const [editValue, setEditValue] = useState("");
const [editMeasuredAt, setEditMeasuredAt] = useState("");
const [editNotes, setEditNotes] = useState("");
const [editSeed, setEditSeed] = useState({
value: "",
measuredAt: "",
notes: "",
});
const [editError, setEditError] = useState<string | null>(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<HTMLDivElement | null>(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<typeof useMeasurementEdit>;
@@ -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<typeof useMeasurementListData>;
@@ -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<Set<string>>(() => 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
>;
@@ -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<typeof useMeasurementRowDisplay>;
File diff suppressed because it is too large. Load diff