Files
HealthLog/src/components/layout/capture-picker.tsx
T
Marc-André Bombeck bf83d414e2 feat: open any past day as a layer over the page
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.

The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.

Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.

Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
2026-10-08 14:20:01 +02:00

377 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Activity, Footprints, Pill, Stethoscope, Waves } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
ManualWorkoutForm,
MeasurementForm,
MedicationIntakeQuickAdd,
MoodForm,
SymptomEntryForm,
} from "@/components/dashboard/quick-entry-forms.lazy";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareDomain } from "@/lib/sharing/scope";
import { useAuth } from "@/hooks/use-auth";
import {
useRecordCapabilities,
type RecordCapabilities,
} from "@/hooks/use-record-capabilities";
/**
* The center "Log" capture action (iOS parity — the bottom bar's
* middle slot is a capture CTA, not a destination). It opens a small
* picker that routes to four existing quick-entry surfaces:
*
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
* - Symptom → `<SymptomEntryForm>` (one of the person's own symptoms,
* v1.40; rides the illness module)
*
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
*
* Nothing here orphans a route: the same surfaces remain reachable
* from their own pages (`/measurements`, `/mood`, dashboard) and from
* the More hub. The picker is an additional fast path, not a removal.
*
* v1.30.1 — the form sheet shares the dashboard quick-entry sheets'
* confirm-before-discard guard (`sheetBodyHasUnsavedInput()`), so a
* swipe-down / backdrop tap / Escape on a half-filled form from the
* primary mobile capture path asks before dropping typed input,
* instead of closing unconditionally.
*/
export type CaptureKind =
"measurement" | "medication" | "mood" | "symptom" | "workout";
/**
* The section each capture surface writes to.
*
* One question per kind, asked of that kind's own section. The picker used to
* ask a coarse one as well — a closed list of kinds a WRITE grant admits,
* offered whenever the grant could write anywhere — and that answer is the
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
* read as true and got a weight form the server refuses; one scoped to
* `["documents"]`, which takes no delegated write at any level, got both.
*
* `writableDomains` is already the level × scope × route-table intersection,
* so asking it per section answers both cases and the mood entry's
* MANAGE-only create as well.
*/
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
measurement: "measurements",
medication: "medications",
mood: "mind",
// A symptom occurrence is an illness-section write, admitted at WRITE.
symptom: "illness",
// A workout goes through the batch ingest, which resolves the caller:
// no grant reaches it, so it is offered in one's own record only.
workout: null,
};
/** The order the chooser lists them in. */
export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
"symptom",
"workout",
];
/**
* The kind whose form may actually be on screen.
*
* Gating the chooser is not enough, and the gap is widest exactly when it
* matters. `resolveRecordCapabilities(undefined)` answers "your own record"
* until `/api/auth/me` settles — deliberately, so no add button blanks on
* every cold load — and the switch flow ends in a hard reload that wipes the
* persisted query cache. So a delegate's first paint offers all kinds for
* as long as that query takes. `chooseKind` refused an unoffered kind at tap
* time and then never asked again, so a tap landing inside that window opened
* a form and left it open once the answer arrived: the delegate ends up
* standing on a form posting to a route the server refuses.
*
* Re-deriving the open form from the CURRENT offer is what closes it. Same
* shape `/measurements` and the medication wizard already use (`open={x &&
* capability}`) — a control that appears and withdraws is a much smaller lie
* than one that stays and 403s.
*
* Pure and exported so the rule can be pinned without a click; an SSR test
* cannot tap the button that opens the sheet.
*/
export function admittedCaptureKind(
kind: CaptureKind | null,
offered: ReadonlyArray<CaptureKind>,
): CaptureKind | null {
return kind !== null && offered.includes(kind) ? kind : null;
}
/**
* The capture kinds to offer, given what the record allows and which of its
* modules are on. Exported pure so both rules can be pinned without opening a
* sheet: an SSR test cannot tap the button that opens it.
*
* A kind whose module is off (`capture:<kind>` in the surface map) is not
* offered: a Mood entry with the mood module off would post to a route that
* refuses it. An absent module map (the account still loading) offers every
* kind the record allows, the gate's default-on contract.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
kinds: ReadonlyArray<CaptureKind>,
modules?: SurfaceModuleMap | null,
): CaptureKind[] {
return kinds.filter((kind) => {
const domain = CAPTURE_KIND_DOMAIN[kind];
const writable =
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
return writable && isSurfaceVisible(`capture:${kind}`, modules);
});
}
interface CapturePickerProps {
/** Whether the picker chooser sheet is open. */
open: boolean;
/** Open-state setter for the chooser sheet. */
onOpenChange: (open: boolean) => void;
/**
* v1.42 — the day the picker was opened from ("Capture for this day" in the
* day view), `YYYY-MM-DD`. Every form starts on it. Omitted, they start now.
*/
defaultDate?: string;
}
export function CapturePicker({
open,
onOpenChange,
defaultDate,
}: CapturePickerProps) {
const { t } = useTranslations();
const capabilities = useRecordCapabilities();
const { user } = useAuth();
const offered = visibleCaptureKinds(
capabilities,
CAPTURE_KIND_ORDER,
user?.modules,
);
const [kind, setKind] = useState<CaptureKind | null>(null);
// Re-derived on every render, never latched: the offer can shrink under a
// sheet that is already open. See `admittedCaptureKind`.
const openKind = admittedCaptureKind(kind, offered);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
// dismiss attempt here instead of closing outright when the form
// body carries unsaved input.
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function chooseKind(next: CaptureKind) {
if (!offered.includes(next)) return;
// Close the chooser first, then open the form sheet so only one
// bottom-sheet is mounted at a time (no stacked backdrops).
onOpenChange(false);
setKind(next);
}
function closeForm() {
setKind(null);
}
// Intercept a form-sheet dismiss: close immediately when the form is
// clean, otherwise keep the sheet open and ask before discarding —
// same contract as `QuickEntrySheets.handleQuickEntryOpenChange`.
function handleFormOpenChange(next: boolean) {
if (next) return;
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
closeForm();
}
const allOptions: ReadonlyArray<{
kind: CaptureKind;
label: string;
description: string;
icon: typeof Activity;
}> = [
{
kind: "measurement",
label: t("nav.capture.measurement"),
description: t("nav.capture.measurementDescription"),
icon: Activity,
},
{
kind: "medication",
label: t("nav.capture.medication"),
description: t("nav.capture.medicationDescription"),
icon: Pill,
},
{
kind: "mood",
label: t("nav.capture.mood"),
description: t("nav.capture.moodDescription"),
icon: Waves,
},
{
kind: "symptom",
label: t("nav.capture.symptom"),
description: t("nav.capture.symptomDescription"),
icon: Stethoscope,
},
{
kind: "workout",
label: t("nav.capture.workout"),
description: t("nav.capture.workoutDescription"),
icon: Footprints,
},
];
const options = allOptions.filter((opt) => offered.includes(opt.kind));
const formTitleByKind: Record<CaptureKind, string> = {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
symptom: t("symptoms.entry.sheetTitle"),
workout: t("insights.workouts.manual.sheetTitle"),
};
// `openKind === null` keeps the form sheet closed (the title is unread
// then); the mood label is the harmless default, matching the prior
// ternary's else branch.
const formTitle = openKind ? formTitleByKind[openKind] : t("mood.addEntry");
return (
<>
{/* The capture-kind chooser. */}
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("nav.capture.title")}
description={t("nav.capture.description")}
>
<div
className="grid grid-cols-1 gap-2"
data-testid="capture-picker-options"
>
{options.map((opt) => (
<button
key={opt.kind}
type="button"
data-testid={`capture-picker-${opt.kind}`}
onClick={() => chooseKind(opt.kind)}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<opt.icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{opt.label}</span>
<span className="text-muted-foreground block text-xs">
{opt.description}
</span>
</span>
</button>
))}
</div>
</ResponsiveSheet>
{/* The chosen capture surface, reusing the existing form. */}
<ResponsiveSheet
open={openKind !== null}
onOpenChange={handleFormOpenChange}
title={formTitle}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{openKind === "measurement" && (
<MeasurementForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "medication" && (
<MedicationIntakeQuickAdd
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "workout" && (
<ManualWorkoutForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "symptom" && (
<SymptomEntryForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "mood" && (
<MoodForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
</ResponsiveSheet>
{/* v1.30.1 — confirm before discarding a partly-filled capture
form when the sheet is dismissed by an overlay tap, Escape,
or a mobile swipe-down. Copy shared with the dashboard
quick-entry sheets' identical guard. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
closeForm();
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}