From d7f0d88d599bf7e9ae1163e986c6e439ebacc2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:59:49 +0200 Subject: [PATCH] refactor(checkups): split the checkups section into form, card and postpone sheet The checkups file held two components of about 620 lines each. Their parts move to components/measurement-reminders/checkups/: - reminder-form: the type groups, cadence presets, the form state and its mapping to and from a reminder (pure) - reminder-form-sheet: the create / edit sheet and its cadence fields - checkup-card: the card in both views, its menu, details, skip and snooze lines, next / last lines and action row - postpone-sheet: the postpone chips and date, the honest skip and its confirmation, and the calendar-day offsets vorsorge-section.tsx keeps the list, the value capture and the visit filing. Markup and behaviour are unchanged; the guards name the modules that now hold what they check. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 7 +- .../baselines/version-stamp-comments.json | 5 +- ...ate-order-explicit-formatter-guard.test.ts | 2 +- .../destructive-control-guard.test.ts | 5 +- .../success-affordance-guard.test.ts | 2 +- ...sorge-due-bucket-single-home-guard.test.ts | 2 +- .../__tests__/vorsorge-section.test.tsx | 3 +- .../checkups/checkup-card.tsx | 710 ++++++++++ .../checkups/postpone-sheet.tsx | 196 +++ .../checkups/reminder-form-sheet.tsx | 285 ++++ .../checkups/reminder-form.ts | 183 +++ .../vorsorge-section.tsx | 1213 +---------------- 13 files changed, 1426 insertions(+), 1192 deletions(-) create mode 100644 src/components/measurement-reminders/checkups/checkup-card.tsx create mode 100644 src/components/measurement-reminders/checkups/postpone-sheet.tsx create mode 100644 src/components/measurement-reminders/checkups/reminder-form-sheet.tsx create mode 100644 src/components/measurement-reminders/checkups/reminder-form.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 18a819df4..9f392e0d0 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": 116, - "total": 134457, + "count": 115, + "total": 132921, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -23,7 +23,6 @@ "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1086, "components/layout/sidebar-nav.tsx": 855, - "components/measurement-reminders/vorsorge-section.tsx": 1536, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1160, "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 d4f349978..98248142f 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": 402, - "total": 138967, + "count": 401, + "total": 138066, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -177,8 +177,7 @@ "components/layout/top-bar.tsx :: TopBar": 212, "components/links/entity-link-picker.tsx :: EntityLinkPicker": 392, "components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215, - "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeCard": 635, - "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 618, + "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352, "components/measurements/measurement-form.tsx :: MeasurementForm": 500, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index dfbd22269..e4168d834 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": 2948, - "total": 10257, + "count": 2947, + "total": 10231, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1120,7 +1120,6 @@ "components/layout/top-bar.tsx": 4, "components/measurement-reminders/__tests__/vorsorge-section.test.tsx": 7, "components/measurement-reminders/vorsorge-dashboard-card.tsx": 2, - "components/measurement-reminders/vorsorge-section.tsx": 26, "components/measurement-reminders/vorsorge-settings.tsx": 2, "components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1, "components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4, diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index 908784bbc..36a2688f7 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -260,7 +260,7 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ why: "`makeDayKeyFormatter` — the per-row bucketing key. Its fields are read back through `formatToParts` and reassembled as `YYYY-MM-DD`.", }, { - file: "src/components/measurement-reminders/vorsorge-section.tsx", + file: "src/components/measurement-reminders/checkups/postpone-sheet.tsx", why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.", }, { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index d28464472..113134c70 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -448,7 +448,10 @@ const REGISTRY: DestructiveEntry[] = [ { file: "hooks/use-measurement-reminders.ts", destroys: "a checkup or measurement reminder with its schedule", - triggers: ["components/measurement-reminders/vorsorge-section.tsx"], + triggers: [ + "components/measurement-reminders/vorsorge-section.tsx", + "components/measurement-reminders/checkups/checkup-card.tsx", + ], recovery: "tombstoned-no-restore", confirm: ["AlertDialog"], }, diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 16f699edd..25f3bfecf 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -233,7 +233,7 @@ const PINNED_AFFORDANCES: Record< "src/components/measurement-reminders/vorsorge-dashboard-card.tsx": { CheckCircle2: 2, }, - "src/components/measurement-reminders/vorsorge-section.tsx": { + "src/components/measurement-reminders/checkups/checkup-card.tsx": { CheckCircle2: 2, "text-success": 1, }, diff --git a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts index 1539b8772..b45198ef3 100644 --- a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts +++ b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts @@ -36,7 +36,7 @@ const HOME_IMPORT = 'from "@/lib/measurement-reminders/due-day"'; * shared body, that is the regression, not a reason to relax the test. */ const RENDERING_SURFACES = [ - "components/measurement-reminders/vorsorge-section.tsx", + "components/measurement-reminders/checkups/checkup-card.tsx", "components/measurement-reminders/vorsorge-dashboard-card.tsx", ]; diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index cd520529c..fc0608642 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -33,7 +33,8 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({ }), })); -import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section"; +import { VorsorgeSection } from "../vorsorge-section"; +import { postponeOffsetTargets } from "../checkups/postpone-sheet"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** diff --git a/src/components/measurement-reminders/checkups/checkup-card.tsx b/src/components/measurement-reminders/checkups/checkup-card.tsx new file mode 100644 index 000000000..94777114b --- /dev/null +++ b/src/components/measurement-reminders/checkups/checkup-card.tsx @@ -0,0 +1,710 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + Activity, + CalendarClock, + CalendarPlus, + CheckCircle2, + LineChart, + MoreVertical, + Pencil, + Trash2, +} from "lucide-react"; + +import { MedicationCardHeader } from "@/components/medications/medication-card-header"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Progress } from "@/components/ui/progress"; +import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { relativeCalendarDate } from "@/lib/i18n/relative-time"; +import { + isOverdueDue, + relativeDueKey, +} from "@/lib/measurement-reminders/due-day"; +import { isScreeningReminderType } from "@/lib/measurement-reminders/screening"; +import { cn } from "@/lib/utils"; + +import { CheckupPostponeSheet } from "./postpone-sheet"; +import { DAY_MS, resolveReminderLabel } from "./reminder-form"; + +/** + * Progress through the current interval toward the next due date, 0 to 100: + * an honest "on track / due" signal without a fabricated streak. Null without + * an interval to measure against (RRULE or unset). + */ +function intervalProgress( + reminder: MeasurementReminder, + now: number, +): number | null { + if (reminder.intervalDays == null || reminder.nextDueAt == null) return null; + const dueMs = new Date(reminder.nextDueAt).getTime(); + if (Number.isNaN(dueMs)) return null; + const windowMs = reminder.intervalDays * DAY_MS; + const startMs = dueMs - windowMs; + const elapsed = now - startMs; + return Math.max(0, Math.min(100, Math.round((elapsed / windowMs) * 100))); +} + +interface CheckupCardProps { + reminder: MeasurementReminder; + now: number; + view: "cards" | "list"; + onPrimaryAction: () => void; + /** Open the visit sheet with this checkup pre-filled, closing it on save. */ + onFileVisit: () => void; + onRemove: () => void; + onEdit: () => void; + /** An honest skip; the server restarts the interval. */ + onSkip: () => void; + /** Snooze to a calendar day (ISO YYYY-MM-DD). */ + onSnooze: (until: string) => void; + busy: boolean; +} + +/** The owner's card menu: edit, the readings for a typed reminder, delete. */ +function CheckupMenu({ + reminder, + isLinked, + busy, + onEdit, + onRequestDelete, +}: { + reminder: MeasurementReminder; + isLinked: boolean; + busy: boolean; + onEdit: () => void; + onRequestDelete: () => void; +}) { + const { t } = useTranslations(); + return ( + + + + + + + + {t("measurementReminders.edit")} + + {/* Put postpone in the kebab. It reads better beside + the primary action, the way the medication card carries "taken" + and "skipped" side by side, so it is a card button now — see + `postponeButton` below. */} + {/* Jump to the measurements list pre-filtered to + this reminder's type. Only for measurement-linked reminders; a + free-text reminder has no readings to show. */} + {isLinked && ( + + + + {t("measurementReminders.showMeasurements")} + + + )} + + + + {t("measurementReminders.delete")} + + + + ); +} + +/** "Pushed back until …" / "skipped on …", muted, never a card colour. */ +function SkipSnoozeMeta({ + reminder, + isSnoozed, + isSkippedCycle, +}: { + reminder: MeasurementReminder; + isSnoozed: boolean; + isSkippedCycle: boolean; +}) { + const fmt = useFormatters(); + const { t } = useTranslations(); + return ( +
+ {isSnoozed && reminder.snoozedUntil && ( +

+ {t("measurementReminders.snoozedUntil", { + date: fmt.date(new Date(reminder.snoozedUntil)), + })} +

+ )} + {isSkippedCycle && reminder.lastSkippedAt && ( +

+ {t("measurementReminders.skippedOn", { + date: fmt.date(new Date(reminder.lastSkippedAt)), + })} + {reminder.skipCount > 1 + ? ` · ${t("measurementReminders.skipCount", { + count: reminder.skipCount, + })}` + : null} +

+ )} +
+ ); +} + +/** + * The next-due and last-done lines in the medication card's grammar, with + * measurement wording (never "intake"). Next due is coloured text, never a + * tint: today in success, overdue in warning. Last done reads relative + * (today, yesterday, else the date), or a dash. + */ +function NextLastSlot({ + reminder, + due, + displayTz, +}: { + reminder: MeasurementReminder; + due: ReturnType; + displayTz: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const lastValue = reminder.lastSatisfiedAt + ? relativeCalendarDate( + reminder.lastSatisfiedAt, + t, + (d) => fmt.date(d), + displayTz, + ) + : "—"; + const dueIsToday = due.key === "measurementReminders.nextDue.today"; + const dueIsOverdue = isOverdueDue(due); + return ( +
+
+ + {t("measurementReminders.nextDueLabel")} + + + {t(due.key, { days: due.days })} + +
+
+ + {t("measurementReminders.lastDoneLabel")} + + {lastValue} +
+
+ ); +} + +/** The dense list variant of a checkup. */ +function CheckupListRow({ + reminder, + due, + categoryLabel, + cadenceText, + meta, + isLinked, + isScreening, + canManageMeasurements, + busy, + onPrimaryAction, + onPostpone, + onFileVisit, + headerActions, +}: { + reminder: MeasurementReminder; + due: ReturnType; + categoryLabel: string; + cadenceText: string | null; + meta: React.ReactNode; + isLinked: boolean; + isScreening: boolean; + canManageMeasurements: boolean; + busy: boolean; + onPrimaryAction: () => void; + onPostpone: () => void; + onFileVisit: () => void; + headerActions: React.ReactNode; +}) { + const { t } = useTranslations(); + return ( + + +
+
+ + {resolveReminderLabel(reminder, t)} + +
+ {/* Cadence rides on the metric line, styled like the + metric text (no shaded pill). */} +
+ + {t(due.key, { days: due.days })} + + {categoryLabel} + {cadenceText ? {cadenceText} : null} +
+ {meta} +
+
+ {/* The same gate the cards branch applies through + `primaryButton`. The list branch used to inline its own + ungated copy, and which branch a person sees is a per-browser + preference that survives the switch — so the identical action + was offered or withheld depending on a view toggle. */} + {canManageMeasurements ? ( + + ) : null} + {/* Icon-only in the dense list, labelled in the cards branch — + the same action either way, never one branch offering what + the other withholds. */} + {canManageMeasurements ? ( + + ) : null} + {/* Free-text reminders only: a typed / screening reminder has no + practice visit behind it. */} + {canManageMeasurements && !isLinked ? ( + + ) : null} + {headerActions} +
+
+
+ ); +} + +/** + * The bottom-pinned action row (owner only): the primary action takes the + * width it can, postpone sits beside it, as the medication card carries taken + * and skipped; a free-text checkup adds "file a visit" below. + */ +function CheckupActionRow({ + isLinked, + isScreening, + busy, + onPrimaryAction, + onPostpone, + onFileVisit, +}: { + isLinked: boolean; + isScreening: boolean; + busy: boolean; + onPrimaryAction: () => void; + onPostpone: () => void; + onFileVisit: () => void; +}) { + const { t } = useTranslations(); + // "File a visit": the second way to close a checkup, beside "done". Only a + // free-text reminder (a real practice visit) carries it; a typed one is + // satisfied by a reading or a check-in. Saving the visit as DONE satisfies + // the reminder through the same forward-only, idempotent path, so doing + // both is one satisfaction. + const fileVisitButton = !isLinked ? ( + + ) : null; + + // Postpone beside the primary action, outline against filled, as the + // medication card carries taken and skipped. + const postponeButton = ( + + ); + + const primaryButton = ( + + ); + + return ( +
+
+ {primaryButton} + {postponeButton} +
+ {fileVisitButton} +
+ ); +} + +/** + * The card's body lines: progress through the interval (hidden for RRULE or + * unscheduled reminders), the course end and the location. + */ +function CheckupDetails({ + reminder, + progress, + isDue, +}: { + reminder: MeasurementReminder; + progress: number | null; + isDue: boolean; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + return ( + <> + {progress != null && ( +
+
+ {t("measurementReminders.adherence.label")} + + {isDue + ? t("measurementReminders.adherence.due") + : t("measurementReminders.adherence.onTrack")} + +
+ +
+ )} + + {reminder.endsOn && ( +

+ {/* `endsOn` is a real instant (`now + courseDays` at Coach + accept, plain timestamptz — NOT a `@db.Date` UTC-midnight + calendar date), so the profile-tz formatter renders the + correct local course-end day for every zone. */} + {t("measurementReminders.until", { + date: fmt.date(new Date(reminder.endsOn)), + })} +

+ )} + {reminder.location && ( +

+ {t("measurementReminders.location.prefix", { + location: reminder.location, + })} +

+ )} + + ); +} + +export function VorsorgeCard({ + reminder, + now, + view, + onPrimaryAction, + onFileVisit, + onRemove, + onEdit, + onSkip, + onSnooze, + busy, +}: CheckupCardProps) { + const { t } = useTranslations(); + const { canManageDomain } = useRecordCapabilities(); + const canManageMeasurements = canManageDomain("measurements"); + // The relative "today / yesterday" bucket uses the zone `fmt.date` renders + // in (#490), so the next-due phrase and the last-done date agree. + const displayTz = useDisplayTimezone(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [postponeOpen, setPostponeOpen] = useState(false); + const due = relativeDueKey(reminder.nextDueAt, now, displayTz); + const cadence = + reminder.intervalDays != null + ? t("measurementReminders.cadence.everyNDays", { + days: reminder.intervalDays, + }) + : reminder.rrule + ? t("measurementReminders.cadence.custom") + : ""; + const isCoach = reminder.origin === "COACH"; + const isLinked = reminder.measurementType != null; + // A screening reminder's action starts the check-in, never numeric entry. + const isScreening = isScreeningReminderType(reminder.measurementType); + const isDue = + due.key === "measurementReminders.nextDue.today" || isOverdueDue(due); + const progress = intervalProgress(reminder, now); + + // The category line: the measurement, or "self-planned" for a free-text + // exam, with the cadence beside it in the same muted style. + const categoryLabel = isLinked + ? t(`measurementReminders.types.${reminder.measurementType}`) + : t("measurementReminders.selfPlanned"); + const cadenceText = cadence || null; + + // A typed reminder's heading links to its readings; a free-text one has + // none and stays a plain heading. + const measurementsHref = isLinked + ? `/measurements?type=${encodeURIComponent(reminder.measurementType as string)}` + : undefined; + + // Provenance and disabled badges get a row only when one applies. + const hasStateBadges = isCoach || !reminder.enabled; + const stateBadges = hasStateBadges ? ( + <> + {isCoach && ( + {t("measurementReminders.originCoach")} + )} + {!reminder.enabled && ( + + {t("measurementReminders.disabledBadge")} + + )} + + ) : undefined; + + const headerActions = canManageMeasurements ? ( + setConfirmDelete(true)} + /> + ) : null; + + const dialogs = ( + <> + + + + + {t("measurementReminders.deleteConfirmTitle")} + + + {t("measurementReminders.deleteConfirmDescription")} + + + + {t("common.cancel")} + + {t("measurementReminders.delete")} + + + + + setPostponeOpen(false)} + busy={busy} + onSnooze={onSnooze} + onSkip={onSkip} + /> + + ); + + // The resolved skip and snooze state: clock comparisons only, since the + // server resolves and the client compares. + const isSnoozed = + reminder.snoozedUntil != null && + new Date(reminder.snoozedUntil).getTime() > now; + const isSkippedCycle = + reminder.lastSkippedAt != null && + (reminder.lastSatisfiedAt == null || + new Date(reminder.lastSkippedAt).getTime() > + new Date(reminder.lastSatisfiedAt).getTime()); + const skipSnoozeMeta = + isSnoozed || isSkippedCycle ? ( + + ) : null; + + if (view === "list") { + return ( + <> + setPostponeOpen(true)} + onFileVisit={onFileVisit} + headerActions={headerActions} + /> + {dialogs} + + ); + } + + return ( + <> + + + + + {skipSnoozeMeta} + + {/* Light gamification: progress through the + current interval toward next-due, reusing the medication card's + Progress building block. Honest "on track / due" signal without + a fabricated streak; hidden for RRULE / unscheduled reminders. */} + + + {/* Bottom-pinned action row: the primary action takes the width it + can, postpone sits beside it at its natural size — the same + two-button shape the medication card uses for taken / skipped. */} + {canManageMeasurements ? ( + setPostponeOpen(true)} + onFileVisit={onFileVisit} + /> + ) : null} + + + + {dialogs} + + ); +} diff --git a/src/components/measurement-reminders/checkups/postpone-sheet.tsx b/src/components/measurement-reminders/checkups/postpone-sheet.tsx new file mode 100644 index 000000000..e13d24e37 --- /dev/null +++ b/src/components/measurement-reminders/checkups/postpone-sheet.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { DAY_MS } from "./reminder-form"; + +/** The offsets the postpone sheet's quick chips offer, in days. */ +const SNOOZE_CHIP_DAYS = [7, 30, 90] as const; + +/** + * The local calendar day (ISO `yyyy-MM-dd`) an instant falls on in `tz`. + * Snooze is day-precision by contract: the server resolves the day to the + * reminder's notifyHour in the profile timezone. + */ +function isoDayInTz(atMs: number, tz: string): string { + return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz }); +} + +/** + * The dates the postpone sheet's quick chips resolve to, on the display zone's + * calendar. + * + * Its own function because a chip labelled "+7 days" has to write the day that + * is actually seven days out, including across a DST shift, where adding + * 7 × 86 400 000 ms lands an hour either side of midnight and so can write the + * neighbouring date. + */ +export function postponeOffsetTargets( + nowMs: number, + tz: string, +): { days: number; date: string }[] { + // Count days on the calendar, not in milliseconds: `now + 7 * DAY_MS` keeps + // the wall-clock time only where the offset stays constant, and a DST shift + // in between moves it an hour, which flips the date for anyone acting near + // midnight. Anchoring today's local date at UTC noon and stepping whole + // UTC days keeps every offset on the day its label names. + const today = isoDayInTz(nowMs, tz); + return SNOOZE_CHIP_DAYS.map((days) => ({ + days, + date: shiftDateKey(today, days), + })); +} + +/** + * The postpone sheet: quick chips and a date to push the checkup back to, + * and set apart below them the honest skip (let this cycle go and restart + * the rhythm) with its own confirmation. + */ +export function CheckupPostponeSheet({ + reminder, + now, + open, + onClose, + busy, + onSnooze, + onSkip, +}: { + reminder: MeasurementReminder; + now: number; + open: boolean; + onClose: () => void; + busy: boolean; + onSnooze: (until: string) => void; + onSkip: () => void; +}) { + const { t } = useTranslations(); + // The day boundary follows the zone dates render in. + const displayTz = useDisplayTimezone(); + const [snoozeDate, setSnoozeDate] = useState(""); + const [confirmSkip, setConfirmSkip] = useState(false); + const closePostpone = () => { + onClose(); + setSnoozeDate(""); + setConfirmSkip(false); + }; + const minSnoozeDate = isoDayInTz(now + DAY_MS, displayTz); + return ( + <> + { + if (!open) closePostpone(); + }} + title={t("measurementReminders.postpone.title")} + description={t("measurementReminders.postpone.description")} + footer={ + <> + + + + } + > +
+ {/* Quick chips write the date field rather than firing directly, so + the person always sees (and can adjust) what will be sent. */} +
+ {postponeOffsetTargets(now, displayTz).map(({ days, date }) => ( + + ))} +
+
+ + +
+ {/* The honest-skip zone sits set apart below the snooze controls: + a different decision (let this cycle go, restart the rhythm) + with its own confirm step. */} +
+

+ {t("measurementReminders.skip.heading")} +

+

+ {t("measurementReminders.skip.explainer")} +

+ +
+
+
+ + + + + {t("measurementReminders.skip.confirmTitle")} + + + {t("measurementReminders.skip.explainer")} + + + + {t("common.cancel")} + { + onSkip(); + closePostpone(); + }} + > + {t("measurementReminders.skip.action")} + + + + + + ); +} diff --git a/src/components/measurement-reminders/checkups/reminder-form-sheet.tsx b/src/components/measurement-reminders/checkups/reminder-form-sheet.tsx new file mode 100644 index 000000000..4678e58e9 --- /dev/null +++ b/src/components/measurement-reminders/checkups/reminder-form-sheet.tsx @@ -0,0 +1,285 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; + +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; +import { isScreeningReminderType } from "@/lib/measurement-reminders/screening"; + +import { + CADENCE_CUSTOM, + CADENCE_RRULE, + INTERVAL_PRESETS, + MENTAL_SCREENING_DEFAULT_INTERVAL, + TYPE_GROUPS, + type FormState, +} from "./reminder-form"; + +/** + * The create / edit sheet: label, kind, measurement type, cadence (preset, + * free interval or RRULE), notification hour, first due date and location. + */ +export function ReminderFormSheet({ + open, + isEdit, + form, + setForm, + saving, + onClose, + onSubmit, +}: { + open: boolean; + isEdit: boolean; + form: FormState; + setForm: Dispatch>; + saving: boolean; + onClose: () => void; + onSubmit: () => void; +}) { + const { t } = useTranslations(); + return ( + { + if (!open) onClose(); + }} + title={t( + isEdit + ? "measurementReminders.form.editTitle" + : "measurementReminders.form.createTitle", + )} + footer={ + <> + + + + } + > + {/* The cadence/type/hour pickers use NativeSelect (not the + Radix Select labs/illness use) deliberately: these are long, + grouped, scalar option lists where the OS-native picker is faster + on touch and avoids a tall scrolling Radix popover inside the + bottom-sheet. The form fields stay NativeSelect by design. */} +
+
+ + setForm((f) => ({ ...f, label: e.target.value }))} + /> +
+ +
+ + + setForm((f) => ({ + ...f, + kind: e.target.value as "type" | "freeText", + })) + } + > + + + +
+ + {form.kind === "type" && ( +
+ + { + const nextType = e.target.value; + setForm((f) => ({ + ...f, + measurementType: nextType, + // A screening covers the last two weeks, so + // switching onto a screening type defaults the cadence + // to every 4 weeks (still freely changeable). + cadenceChoice: isScreeningReminderType(nextType) + ? MENTAL_SCREENING_DEFAULT_INTERVAL + : f.cadenceChoice, + })); + }} + > + {TYPE_GROUPS.map(({ group, types }) => ( + + {types.map((type) => ( + + ))} + + ))} + +
+ )} + + + +
+ + setForm((f) => ({ ...f, anchorDate: value }))} + /> +
+ +
+ + + setForm((f) => ({ ...f, location: e.target.value })) + } + /> +
+
+
+ ); +} + +/** + * The cadence (preset, free interval or RRULE) and the notification hour. + * The pickers are native selects on purpose: long, grouped scalar lists where + * the system picker is faster on touch than a tall popover inside a sheet. + */ +function CadenceFields({ + form, + setForm, +}: { + form: FormState; + setForm: Dispatch>; +}) { + const { t } = useTranslations(); + const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM; + const cadenceRrule = form.cadenceChoice === CADENCE_RRULE; + return ( + <> +
+
+ + + setForm((f) => ({ ...f, cadenceChoice: e.target.value })) + } + > + {INTERVAL_PRESETS.map((days) => ( + + ))} + + + +
+
+ + + setForm((f) => ({ ...f, notifyHour: Number(e.target.value) })) + } + > + {Array.from({ length: 24 }, (_, h) => ( + + ))} + +
+
+ + {cadenceCustom && ( +
+ + + setForm((f) => ({ + ...f, + customIntervalDays: Number(e.target.value), + })) + } + /> +
+ )} + + {cadenceRrule && ( +
+ + setForm((f) => ({ ...f, rrule: e.target.value }))} + /> +

+ {t("measurementReminders.form.rruleHint")} +

+
+ )} + + ); +} diff --git a/src/components/measurement-reminders/checkups/reminder-form.ts b/src/components/measurement-reminders/checkups/reminder-form.ts new file mode 100644 index 000000000..946a087e0 --- /dev/null +++ b/src/components/measurement-reminders/checkups/reminder-form.ts @@ -0,0 +1,183 @@ +import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; +import { translateOrNull } from "@/lib/i18n/translate-code"; +import type { ReminderEditBody } from "@/lib/measurement-reminders/edit-body"; +import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format"; + +// The active-measurement set, grouped so the dropdown +// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in +// `validations/measurement-reminders.ts`; the labels resolve through +// `measurementReminders.types.*`, the group headers through +// `measurementReminders.typeGroups.*`. +export const TYPE_GROUPS = [ + { + group: "vitals", + types: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + ], + }, + { + group: "bodyComposition", + types: [ + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + "WAIST_CIRCUMFERENCE", + ], + }, + // The mental-wellbeing screenings become plannable like any + // other Vorsorge item. Auto-resolve rides the server-written *_SCORE row a + // completed check-in produces (v1.27.9 adds WHO-5 + SCI). + { + group: "mentalWellbeing", + types: ["PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE", "SCI_SCORE"], + }, +] as const; + +// Screening types get a dedicated primary action (the check-in +// page, not the numeric MeasurementForm) and a 4-week default cadence +// (both instruments cover the last two weeks). +export const MENTAL_SCREENING_DEFAULT_INTERVAL = "28"; + +// 28 joined in v1.27.6 as the screening default (every 4 weeks); 30 stays +// so existing monthly reminders keep resolving onto a preset. +export const INTERVAL_PRESETS = [7, 14, 28, 30, 90, 180, 365] as const; +// The sentinel the cadence picker writes when the user wants a +// free interval or an RFC-5545 RRULE instead of a fixed preset. +export const CADENCE_CUSTOM = "custom"; +export const CADENCE_RRULE = "rrule"; +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A COACH-minted reminder stores an i18n KEY in `label` (the + * cadence preset's `labelKey`), not free prose. Resolve it through `t()`; + * a user-created (VORSORGE) reminder carries free text and renders verbatim. + */ +export function resolveReminderLabel( + reminder: MeasurementReminder, + t: (key: string) => string, +): string { + if (reminder.origin === "COACH") { + // A label the catalogue has no sentence for shows as written, so a future + // catalog addition never surfaces a bare key. + return translateOrNull(t, reminder.label) ?? reminder.label; + } + return reminder.label; +} + +/** ISO instant → the `` value (YYYY-MM-DD), local. */ +export function toDateInputValue(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return userDayKey(d, detectBrowserTimezone()); +} + +/** + * `` value → the calendar date itself, or null. The server + * reads a bare date as that day in the profile's zone, so a browser set to + * another zone cannot move the first-due day. + */ +export function fromDateInputValue(value: string): string | null { + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; +} + +export interface FormState { + label: string; + kind: "type" | "freeText"; + measurementType: string; + /** A preset number-as-string, or the CADENCE_CUSTOM / CADENCE_RRULE sentinel. */ + cadenceChoice: string; + /** Free "every N days" value, used when cadenceChoice === CADENCE_CUSTOM. */ + customIntervalDays: number; + /** Raw RRULE, used when cadenceChoice === CADENCE_RRULE. */ + rrule: string; + /** First-due date (YYYY-MM-DD), optional. Maps to `anchorDate`. */ + anchorDate: string; + notifyHour: number; + location: string; +} + +export const EMPTY_FORM: FormState = { + label: "", + kind: "type", + measurementType: "BLOOD_PRESSURE_SYS", + cadenceChoice: "7", + customIntervalDays: 30, + rrule: "FREQ=YEARLY", + anchorDate: "", + notifyHour: 9, + location: "", +}; + +/** The form as a stored reminder opens it, cadence resolved onto the picker. */ +export function formFromReminder( + reminder: MeasurementReminder, + t: (key: string) => string, +): FormState { + // A preset value if it matches one, otherwise the custom-interval or RRULE + // branch. + const interval = reminder.intervalDays; + const cadenceChoice = reminder.rrule + ? CADENCE_RRULE + : interval != null && + (INTERVAL_PRESETS as readonly number[]).includes(interval) + ? String(interval) + : interval != null + ? CADENCE_CUSTOM + : "7"; + return { + label: resolveReminderLabel(reminder, t), + kind: reminder.measurementType ? "type" : "freeText", + measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS", + cadenceChoice, + customIntervalDays: + cadenceChoice === CADENCE_CUSTOM && interval != null ? interval : 30, + rrule: reminder.rrule ?? "FREQ=YEARLY", + anchorDate: toDateInputValue(reminder.anchorDate), + notifyHour: reminder.notifyHour, + location: reminder.location ?? "", + }; +} + +/** + * The picker state as the mutually exclusive cadence pair the schema expects + * (exactly one of intervalDays / rrule). + */ +function resolveCadence(state: FormState): { + intervalDays: number | null; + rrule: string | null; +} { + if (state.cadenceChoice === CADENCE_RRULE) { + const trimmed = state.rrule.trim(); + return { intervalDays: null, rrule: trimmed || null }; + } + if (state.cadenceChoice === CADENCE_CUSTOM) { + return { intervalDays: state.customIntervalDays, rrule: null }; + } + return { intervalDays: Number(state.cadenceChoice), rrule: null }; +} + +/** The form as the request body a create or an edit sends. */ +export function toEditBody(state: FormState): ReminderEditBody { + const { intervalDays, rrule } = resolveCadence(state); + return { + label: state.label.trim(), + measurementType: state.kind === "type" ? state.measurementType : null, + intervalDays, + rrule, + anchorDate: fromDateInputValue(state.anchorDate), + notifyHour: state.notifyHour, + location: state.location.trim() || null, + }; +} diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 654cc6f9d..055c731c9 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -1,227 +1,46 @@ "use client"; -/** - * v1.17.1 — Vorsorge (preventive-care / measurement) reminder surface. - * - * Answers "wann muss ich was wo machen": a grid of upcoming reminders - * sorted by server-computed next-due, each rendered as its OWN card that - * mirrors the medication card grammar (neutral surface, header + kebab, - * a next/last block, and a single bottom-pinned action). Per the project - * rule the card stays NEUTRAL regardless of due state — no alarming - * red/green tint; status reads through a discreet badge only. - * - * The server is authoritative for `nextDueAt`; this component renders it - * relative to "now" but never recomputes the cadence. - * - * v1.18.2 — rebuilt to mirror the MEDICATION module: - * - each reminder is its own med-styled card (`VorsorgeCard`) reusing - * `MedicationCardHeader` / `MedicationNextLastSlot` and the - * `MedicationIntakeActions` bottom-pinned action treatment; - * - the mark-done action branches on `measurementType`: a free-text / - * self-planned exam keeps the silent satisfy ("Erledigt"); a - * measurement-linked reminder opens the real `MeasurementForm` - * ("Wert erfassen") so completing a BP reminder lands an actual - * reading, then satisfies the reminder in the same user action; - * - the create/edit form gains a first-due date (`anchorDate`) and a - * custom cadence (free "every N days" + an RFC-5545 RRULE option); - * - a wrench/customize affordance sits beside the page-variant Add, - * hosting the per-reminder enable/disable toggles (moved off the - * card so the card carries a single med-style kebab). - */ -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { - Activity, - CalendarClock, - CalendarPlus, - CheckCircle2, - Plus, - Wrench, -} from "lucide-react"; +import { CalendarClock, Plus, Wrench } from "lucide-react"; -import { - useFormatters, - useTranslations, - useDisplayTimezone, -} from "@/lib/i18n/context"; -import { relativeCalendarDate } from "@/lib/i18n/relative-time"; -import { - isOverdueDue, - relativeDueKey, -} from "@/lib/measurement-reminders/due-day"; -import { - changedReminderFields, - type ReminderEditBody, -} from "@/lib/measurement-reminders/edit-body"; -import { cn } from "@/lib/utils"; -import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { EncounterSheet } from "@/components/encounters/encounter-sheet"; +import { MeasurementForm } from "@/components/measurements/measurement-form"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { PageHeader } from "@/components/ui/page-header"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { DateField } from "@/components/ui/date-field"; import { EmptyState } from "@/components/ui/empty-state"; +import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { LineChart, MoreVertical, Pencil, Trash2 } from "lucide-react"; -import { NativeSelect } from "@/components/ui/native-select"; -import { Progress } from "@/components/ui/progress"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { MeasurementForm } from "@/components/measurements/measurement-form"; -import { MedicationCardHeader } from "@/components/medications/medication-card-header"; -import { isScreeningReminderType } from "@/lib/measurement-reminders/screening"; import { - useMeasurementReminders, useMeasurementReminderMutations, + useMeasurementReminders, type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useTranslations } from "@/lib/i18n/context"; +import { changedReminderFields } from "@/lib/measurement-reminders/edit-body"; +import { isScreeningReminderType } from "@/lib/measurement-reminders/screening"; +import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; + +import { VorsorgeCard } from "./checkups/checkup-card"; import { - detectBrowserTimezone, - shiftDateKey, - userDayKey, -} from "@/lib/tz/format"; -import { translateOrNull } from "@/lib/i18n/translate-code"; - -// v1.18.1 — the active-measurement set, grouped so the dropdown -// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in -// `validations/measurement-reminders.ts`; the labels resolve through -// `measurementReminders.types.*`, the group headers through -// `measurementReminders.typeGroups.*`. -const TYPE_GROUPS = [ - { - group: "vitals", - types: [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "PULSE", - "BLOOD_GLUCOSE", - "OXYGEN_SATURATION", - "BODY_TEMPERATURE", - ], - }, - { - group: "bodyComposition", - types: [ - "BODY_FAT", - "FAT_MASS", - "FAT_FREE_MASS", - "MUSCLE_MASS", - "LEAN_BODY_MASS", - "BONE_MASS", - "TOTAL_BODY_WATER", - "VISCERAL_FAT", - "BODY_MASS_INDEX", - "WAIST_CIRCUMFERENCE", - ], - }, - // v1.27.6 — the mental-wellbeing screenings become plannable like any - // other Vorsorge item. Auto-resolve rides the server-written *_SCORE row a - // completed check-in produces (v1.27.9 adds WHO-5 + SCI). - { - group: "mentalWellbeing", - types: ["PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE", "SCI_SCORE"], - }, -] as const; - -// v1.27.6 — screening types get a dedicated primary action (the check-in -// page, not the numeric MeasurementForm) and a 4-week default cadence -// (both instruments cover the last two weeks). -const MENTAL_SCREENING_DEFAULT_INTERVAL = "28"; - -// 28 joined in v1.27.6 as the screening default (every 4 weeks); 30 stays -// so existing monthly reminders keep resolving onto a preset. -const INTERVAL_PRESETS = [7, 14, 28, 30, 90, 180, 365] as const; -// v1.18.2 — the sentinel the cadence picker writes when the user wants a -// free interval or an RFC-5545 RRULE instead of a fixed preset. -const CADENCE_CUSTOM = "custom"; -const CADENCE_RRULE = "rrule"; -const DAY_MS = 24 * 60 * 60 * 1000; + EMPTY_FORM, + formFromReminder, + toEditBody, + type FormState, +} from "./checkups/reminder-form"; +import { ReminderFormSheet } from "./checkups/reminder-form-sheet"; /** - * v1.18.1 — a COACH-minted reminder stores an i18n KEY in `label` (the - * cadence preset's `labelKey`), not free prose. Resolve it through `t()`; - * a user-created (VORSORGE) reminder carries free text and renders verbatim. + * The checkups surface (the `/checkups` page and its settings card): the + * reminder list, the create / edit sheet, the value capture that completes a + * typed reminder with a real reading, and filing a visit against one. The + * form, the card and the postpone sheet live in `./checkups/`. */ -function resolveReminderLabel( - reminder: MeasurementReminder, - t: (key: string) => string, -): string { - if (reminder.origin === "COACH") { - // A label the catalogue has no sentence for shows as written, so a future - // catalog addition never surfaces a bare key. - return translateOrNull(t, reminder.label) ?? reminder.label; - } - return reminder.label; -} - -/** ISO instant → the `` value (YYYY-MM-DD), local. */ -function toDateInputValue(iso: string | null): string { - if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - return userDayKey(d, detectBrowserTimezone()); -} - -/** - * `` value → the calendar date itself, or null. The server - * reads a bare date as that day in the profile's zone, so a browser set to - * another zone cannot move the first-due day. - */ -function fromDateInputValue(value: string): string | null { - return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; -} - -interface FormState { - label: string; - kind: "type" | "freeText"; - measurementType: string; - /** A preset number-as-string, or the CADENCE_CUSTOM / CADENCE_RRULE sentinel. */ - cadenceChoice: string; - /** Free "every N days" value, used when cadenceChoice === CADENCE_CUSTOM. */ - customIntervalDays: number; - /** Raw RRULE, used when cadenceChoice === CADENCE_RRULE. */ - rrule: string; - /** First-due date (YYYY-MM-DD), optional. Maps to `anchorDate`. */ - anchorDate: string; - notifyHour: number; - location: string; -} - -const EMPTY_FORM: FormState = { - label: "", - kind: "type", - measurementType: "BLOOD_PRESSURE_SYS", - cadenceChoice: "7", - customIntervalDays: 30, - rrule: "FREQ=YEARLY", - anchorDate: "", - notifyHour: 9, - location: "", -}; - export function VorsorgeSection({ enabled = true, variant = "settings", @@ -248,7 +67,7 @@ export function VorsorgeSection({ } = useMeasurementReminders(enabled); const { create, update, remove, satisfy, skip, snooze } = useMeasurementReminderMutations(); - // v1.18.6 — page view (cards/list) + manual order persist + // Page view (cards/list) + manual order persist // client-side; the settings page writes them, the page reads them. const { prefs } = useModuleListPrefs("vorsorge"); @@ -294,29 +113,7 @@ export function VorsorgeSection({ } function openEdit(reminder: MeasurementReminder) { - // Resolve the stored cadence back onto the picker: a preset value if it - // matches one, otherwise the custom-interval or RRULE branch. - const interval = reminder.intervalDays; - const cadenceChoice = reminder.rrule - ? CADENCE_RRULE - : interval != null && - (INTERVAL_PRESETS as readonly number[]).includes(interval) - ? String(interval) - : interval != null - ? CADENCE_CUSTOM - : "7"; - const opened: FormState = { - label: resolveReminderLabel(reminder, t), - kind: reminder.measurementType ? "type" : "freeText", - measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS", - cadenceChoice, - customIntervalDays: - cadenceChoice === CADENCE_CUSTOM && interval != null ? interval : 30, - rrule: reminder.rrule ?? "FREQ=YEARLY", - anchorDate: toDateInputValue(reminder.anchorDate), - notifyHour: reminder.notifyHour, - location: reminder.location ?? "", - }; + const opened = formFromReminder(reminder, t); setForm(opened); setInitialForm(opened); setEditing(reminder); @@ -327,35 +124,6 @@ export function VorsorgeSection({ setForm(EMPTY_FORM); } - // Resolve the picker state to the mutually-exclusive cadence pair the - // schema expects (exactly one of intervalDays / rrule). - function resolveCadence(state: FormState): { - intervalDays: number | null; - rrule: string | null; - } { - if (state.cadenceChoice === CADENCE_RRULE) { - const trimmed = state.rrule.trim(); - return { intervalDays: null, rrule: trimmed || null }; - } - if (state.cadenceChoice === CADENCE_CUSTOM) { - return { intervalDays: state.customIntervalDays, rrule: null }; - } - return { intervalDays: Number(state.cadenceChoice), rrule: null }; - } - - function toEditBody(state: FormState): ReminderEditBody { - const { intervalDays, rrule } = resolveCadence(state); - return { - label: state.label.trim(), - measurementType: state.kind === "type" ? state.measurementType : null, - intervalDays, - rrule, - anchorDate: fromDateInputValue(state.anchorDate), - notifyHour: state.notifyHour, - location: state.location.trim() || null, - }; - } - function submit() { const trimmed = form.label.trim(); if (!trimmed) return; @@ -378,7 +146,7 @@ export function VorsorgeSection({ // The mark-done action. A free-text / self-planned exam satisfies // silently; a measurement-linked reminder opens the real value-entry // form so the user logs an actual reading. A screening reminder - // (v1.27.6) routes to the check-in page instead — the score is never + // Routes to the check-in page instead — the score is never // typed in, it falls out of a completed test, which then auto-satisfies // the reminder through the eventful worker. function onPrimaryAction(reminder: MeasurementReminder) { @@ -401,12 +169,10 @@ export function VorsorgeSection({ } const saving = create.isPending || update.isPending; - const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM; - const cadenceRrule = form.cadenceChoice === CADENCE_RRULE; - // v1.18.6 — the primary add affordance reads "hinzufügen" like + // The primary add affordance reads "hinzufügen" like // every other module's add button. - // v1.36.x — a checkup reminder belongs to the person whose schedule it is, + // .x — a checkup reminder belongs to the person whose schedule it is, // and no grant level admits creating, editing or satisfying one. Inside // somebody else's record the schedule stays readable and every control that // would change it is absent. @@ -421,7 +187,7 @@ export function VorsorgeSection({ ) : null; - // v1.18.6 — the wrench links to the Vorsorge settings page (view, + // The wrench links to the Vorsorge settings page (view, // reorder, per-reminder enable toggles), left of the Add button — the // canonical medication-page pattern. The old kebab "Anpassen" sheet is // gone (MOD-07); its toggles moved to the settings page. `/settings` is the @@ -506,230 +272,17 @@ export function VorsorgeSection({ /> )} - { - if (!open) closeSheet(); - }} - title={t( - isEdit - ? "measurementReminders.form.editTitle" - : "measurementReminders.form.createTitle", - )} - footer={ - <> - - - - } - > - {/* v1.18.1 — the cadence/type/hour pickers use NativeSelect (not the - Radix Select labs/illness use) deliberately: these are long, - grouped, scalar option lists where the OS-native picker is faster - on touch and avoids a tall scrolling Radix popover inside the - bottom-sheet. The form fields stay NativeSelect by design. */} -
-
- - - setForm((f) => ({ ...f, label: e.target.value })) - } - /> -
+ isEdit={isEdit} + form={form} + setForm={setForm} + saving={saving} + onClose={closeSheet} + onSubmit={submit} + /> -
- - - setForm((f) => ({ - ...f, - kind: e.target.value as "type" | "freeText", - })) - } - > - - - -
- - {form.kind === "type" && ( -
- - { - const nextType = e.target.value; - setForm((f) => ({ - ...f, - measurementType: nextType, - // v1.27.6 — a screening covers the last two weeks, so - // switching onto a screening type defaults the cadence - // to every 4 weeks (still freely changeable). - cadenceChoice: isScreeningReminderType(nextType) - ? MENTAL_SCREENING_DEFAULT_INTERVAL - : f.cadenceChoice, - })); - }} - > - {TYPE_GROUPS.map(({ group, types }) => ( - - {types.map((type) => ( - - ))} - - ))} - -
- )} - -
-
- - - setForm((f) => ({ ...f, cadenceChoice: e.target.value })) - } - > - {INTERVAL_PRESETS.map((days) => ( - - ))} - - - -
-
- - - setForm((f) => ({ ...f, notifyHour: Number(e.target.value) })) - } - > - {Array.from({ length: 24 }, (_, h) => ( - - ))} - -
-
- - {cadenceCustom && ( -
- - - setForm((f) => ({ - ...f, - customIntervalDays: Number(e.target.value), - })) - } - /> -
- )} - - {cadenceRrule && ( -
- - - setForm((f) => ({ ...f, rrule: e.target.value })) - } - /> -

- {t("measurementReminders.form.rruleHint")} -

-
- )} - -
- - - setForm((f) => ({ ...f, anchorDate: value })) - } - /> -
- -
- - - setForm((f) => ({ ...f, location: e.target.value })) - } - /> -
-
-
- - {/* v1.18.2 — value-capture sheet: a measurement-linked reminder opens + {/* Value-capture sheet: a measurement-linked reminder opens the real MeasurementForm pre-filled with its type, so completing a BP reminder logs an actual reading. On success the reminder is satisfied in the same action. */} @@ -840,697 +393,3 @@ export function VorsorgeSection({ ); } - -/** - * v1.18.6 — progress through the current interval toward the next - * due date, as a 0–100 percentage. Honest "are we on track" gamification - * that reuses the medication card's `Progress` building block without - * fabricating a streak the reminder data cannot support: 100 % (or beyond) - * means due/overdue now, lower means time still remains in the window. - * Returns null when there is no interval to measure against (RRULE / unset). - */ -/** v1.37.20 (#223) — the offsets the postpone menu offers, in days. */ -const SNOOZE_CHIP_DAYS = [7, 30, 90] as const; - -/** - * The local calendar day (ISO `yyyy-MM-dd`) an instant falls on in `tz`. - * Snooze is day-precision by contract: the server resolves the day to the - * reminder's notifyHour in the profile timezone. - */ -function isoDayInTz(atMs: number, tz: string): string { - return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz }); -} - -/** - * The dates the postpone sheet's quick chips resolve to, on the display zone's - * calendar. - * - * Its own function because a chip labelled "+7 days" has to write the day that - * is actually seven days out, including across a DST shift, where adding - * 7 × 86 400 000 ms lands an hour either side of midnight and so can write the - * neighbouring date. - */ -export function postponeOffsetTargets( - nowMs: number, - tz: string, -): { days: number; date: string }[] { - // Count days on the calendar, not in milliseconds: `now + 7 * DAY_MS` keeps - // the wall-clock time only where the offset stays constant, and a DST shift - // in between moves it an hour, which flips the date for anyone acting near - // midnight. Anchoring today's local date at UTC noon and stepping whole - // UTC days keeps every offset on the day its label names. - const today = isoDayInTz(nowMs, tz); - return SNOOZE_CHIP_DAYS.map((days) => ({ - days, - date: shiftDateKey(today, days), - })); -} - -function intervalProgress( - reminder: MeasurementReminder, - now: number, -): number | null { - if (reminder.intervalDays == null || reminder.nextDueAt == null) return null; - const dueMs = new Date(reminder.nextDueAt).getTime(); - if (Number.isNaN(dueMs)) return null; - const windowMs = reminder.intervalDays * DAY_MS; - const startMs = dueMs - windowMs; - const elapsed = now - startMs; - return Math.max(0, Math.min(100, Math.round((elapsed / windowMs) * 100))); -} - -function VorsorgeCard({ - reminder, - now, - view, - onPrimaryAction, - onFileVisit, - onRemove, - onEdit, - onSkip, - onSnooze, - busy, -}: { - reminder: MeasurementReminder; - now: number; - view: "cards" | "list"; - onPrimaryAction: () => void; - /** Open the visit sheet with this checkup pre-filled, closing it on save. */ - onFileVisit: () => void; - onRemove: () => void; - onEdit: () => void; - /** v1.37.20 (#223) — honest skip; the server restarts the interval. */ - onSkip: () => void; - /** v1.37.20 (#223) — snooze to a calendar day (ISO YYYY-MM-DD). */ - onSnooze: (until: string) => void; - busy: boolean; -}) { - const { t } = useTranslations(); - const fmt = useFormatters(); - const { canManageDomain } = useRecordCapabilities(); - const canManageMeasurements = canManageDomain("measurements"); - // Issue #490 — day-boundary zone for the relative "today / yesterday" - // bucket must match the zone `fmt.date` renders in (mirror → Berlin). - // The next-due phrase reads off the SAME zone as the last-done date below - // it; they used to disagree by a day for anyone whose device sat in another - // zone from their profile. - const displayTz = useDisplayTimezone(); - const [confirmDelete, setConfirmDelete] = useState(false); - // v1.37.20 (#223) — the postpone sheet (snooze quick-chips + date field, - // with the honest-skip zone below) and the skip confirm inside it. - const [postponeOpen, setPostponeOpen] = useState(false); - const [snoozeDate, setSnoozeDate] = useState(""); - const [confirmSkip, setConfirmSkip] = useState(false); - const due = relativeDueKey(reminder.nextDueAt, now, displayTz); - const cadence = - reminder.intervalDays != null - ? t("measurementReminders.cadence.everyNDays", { - days: reminder.intervalDays, - }) - : reminder.rrule - ? t("measurementReminders.cadence.custom") - : ""; - const isCoach = reminder.origin === "COACH"; - const isLinked = reminder.measurementType != null; - // v1.27.5 — due-ness never changes the ACTION BUTTON: it keeps one - // constant look in every state (same rule as the medication card's - // constant surface). Due / overdue reads only through the discreet - // coloured next-due text + the adherence meta line below. - // v1.27.6 — a screening reminder's action is "start the check-in" (it - // routes to the check-in page), never the numeric value-entry wording. - const isScreening = isScreeningReminderType(reminder.measurementType); - const isDue = - due.key === "measurementReminders.nextDue.today" || isOverdueDue(due); - const progress = intervalProgress(reminder, now); - - // Category-style header badge: the measurement label, or "self-planned" - // for a free-text exam — mirroring the medication card's class badge. - const categoryLabel = isLinked - ? t(`measurementReminders.types.${reminder.measurementType}`) - : t("measurementReminders.selfPlanned"); - - // v1.18.10 — the cadence reads as part of the METRIC ROW (the category - // line), styled like the metric label itself — same muted weight/colour, - // no shaded pill. The card heading stays clean (nothing beside it); the - // cadence rides next to "Blutdruck" et al. via `metricMeta` below. - const cadenceText = cadence || null; - - // v1.18.10 — a measurement-linked reminder's heading links to the - // measurements history pre-filtered to its type (the same target the - // "Messergebnisse anzeigen" kebab item uses). A free-text reminder has no - // readings to show, so it stays a plain (non-linking) heading. - const measurementsHref = isLinked - ? `/measurements?type=${encodeURIComponent(reminder.measurementType as string)}` - : undefined; - - // Provenance / disabled state badges keep their own row below the category. - // Omit the row entirely when neither applies (don't render an empty band). - const hasStateBadges = isCoach || !reminder.enabled; - const stateBadges = hasStateBadges ? ( - <> - {isCoach && ( - {t("measurementReminders.originCoach")} - )} - {!reminder.enabled && ( - - {t("measurementReminders.disabledBadge")} - - )} - - ) : undefined; - - // Single med-style kebab: Edit + Delete. The Delete item is the shared - // confirm-on-delete control rendered as a full-width menu row. - const headerActions = canManageMeasurements ? ( - - - - - - - - {t("measurementReminders.edit")} - - {/* v1.37.20 (#223) put postpone in the kebab. It reads better beside - the primary action, the way the medication card carries "taken" - and "skipped" side by side, so it is a card button now — see - `postponeButton` below. */} - {/* v1.18.7 — jump to the measurements list pre-filtered to - this reminder's type. Only for measurement-linked reminders; a - free-text reminder has no readings to show. */} - {isLinked && ( - - - - {t("measurementReminders.showMeasurements")} - - - )} - - setConfirmDelete(true)} - > - - {t("measurementReminders.delete")} - - - - ) : null; - - // The measure / mark-done action. One constant appearance in every state — - // no due-driven tint, no label swap (v1.27.5 retired the former green - // "do it now" wash; a button that changes colour by schedule state reads as - // a different control). Due-ness is communicated by the discreet coloured - // next-due text above. - /** - * "Termin eintragen" — the second way to close a checkup. - * - * Beside "erledigt" rather than instead of it, because the two answer - * different questions: one says the thing happened, the other says WHERE it - * happened and files the record of it. Saving the visit as DONE satisfies - * this reminder through the same forward-only, idempotent primitive the - * checkbox uses, so a person who does both gets one satisfaction and no - * error. - */ - // The file-visit action belongs only to a free-text reminder — a real - // practice visit, where "you went" is recorded by filing a visit. A typed - // reminder (Gewicht, Blutdruck, a screening check-in) is satisfied by a - // reading or a check-in inside the app, so it carries no visit affordance. - // `resolve.ts` states the same boundary server-side: an appointment reminder - // is free-text by construction. - const fileVisitButton = - canManageMeasurements && !isLinked ? ( - - ) : null; - - // Postponing sits beside the primary action rather than inside the kebab, - // the way the medication card carries "taken" and "skipped" as two buttons - // on the card. Outline against the filled primary: the same pairing, and - // the same rule as everything else in this bar — one constant look, never - // tinted by how overdue the checkup is. - const postponeButton = canManageMeasurements ? ( - - ) : null; - - const primaryButton = canManageMeasurements ? ( - - ) : null; - - const deleteDialog = ( - - - - - {t("measurementReminders.deleteConfirmTitle")} - - - {t("measurementReminders.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("measurementReminders.delete")} - - - - - ); - - // v1.37.20 (#223) — resolved skip/snooze state, clock comparisons only - // (the DTO comment states the contract: the server resolves, the client - // compares). Meta lines are muted text — never a card colour change. - const isSnoozed = - reminder.snoozedUntil != null && - new Date(reminder.snoozedUntil).getTime() > now; - const isSkippedCycle = - reminder.lastSkippedAt != null && - (reminder.lastSatisfiedAt == null || - new Date(reminder.lastSkippedAt).getTime() > - new Date(reminder.lastSatisfiedAt).getTime()); - - const closePostpone = () => { - setPostponeOpen(false); - setSnoozeDate(""); - setConfirmSkip(false); - }; - const minSnoozeDate = isoDayInTz(now + DAY_MS, displayTz); - - const postponeSheet = ( - { - if (!open) closePostpone(); - }} - title={t("measurementReminders.postpone.title")} - description={t("measurementReminders.postpone.description")} - footer={ - <> - - - - } - > -
- {/* Quick chips write the date field rather than firing directly, so - the person always sees (and can adjust) what will be sent. */} -
- {postponeOffsetTargets(now, displayTz).map(({ days, date }) => ( - - ))} -
-
- - -
- {/* The honest-skip zone sits set apart below the snooze controls: - a different decision (let this cycle go, restart the rhythm) - with its own confirm step. */} -
-

- {t("measurementReminders.skip.heading")} -

-

- {t("measurementReminders.skip.explainer")} -

- -
-
-
- ); - - const skipConfirmDialog = ( - - - - - {t("measurementReminders.skip.confirmTitle")} - - - {t("measurementReminders.skip.explainer")} - - - - {t("common.cancel")} - { - onSkip(); - closePostpone(); - }} - > - {t("measurementReminders.skip.action")} - - - - - ); - - // Muted meta lines shared by both branches: "pushed back until {date}" / - // "skipped on {date}" (+ lifetime count once it says something). "Last - // done" stays lastSatisfiedAt — a skip never reads as done. - const skipSnoozeMeta = - isSnoozed || isSkippedCycle ? ( -
- {isSnoozed && reminder.snoozedUntil && ( -

- {t("measurementReminders.snoozedUntil", { - date: fmt.date(new Date(reminder.snoozedUntil)), - })} -

- )} - {isSkippedCycle && reminder.lastSkippedAt && ( -

- {t("measurementReminders.skippedOn", { - date: fmt.date(new Date(reminder.lastSkippedAt)), - })} - {reminder.skipCount > 1 - ? ` · ${t("measurementReminders.skipCount", { - count: reminder.skipCount, - })}` - : null} -

- )} -
- ) : null; - - // v1.18.6 — compact list row variant. - if (view === "list") { - return ( - <> - {/* Density lives on the Card (py-3 vs the default py-4 md:py-6); - the content slot keeps its own px-4 md:px-6 contract. */} - - -
-
- - {resolveReminderLabel(reminder, t)} - -
- {/* v1.18.10 — cadence rides on the metric line, styled like the - metric text (no shaded pill). */} -
- - {t(due.key, { days: due.days })} - - {categoryLabel} - {cadenceText ? {cadenceText} : null} -
- {skipSnoozeMeta} -
-
- {/* The same gate the cards branch applies through - `primaryButton`. The list branch used to inline its own - ungated copy, and which branch a person sees is a per-browser - preference that survives the switch — so the identical action - was offered or withheld depending on a view toggle. */} - {canManageMeasurements ? ( - - ) : null} - {/* Icon-only in the dense list, labelled in the cards branch — - the same action either way, never one branch offering what - the other withholds. */} - {canManageMeasurements ? ( - - ) : null} - {/* Free-text reminders only: a typed / screening reminder has no - practice visit behind it. */} - {canManageMeasurements && !isLinked ? ( - - ) : null} - {headerActions} -
-
-
- {deleteDialog} - {postponeSheet} - {skipConfirmDialog} - - ); - } - - // Next/last block in the med-card grammar, but with MEASUREMENT wording - // (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The - // "next" value carries the discreet neutral due badge; the "last" value is - // the last satisfied date. - // v1.18.9 (#39) — "zuletzt erledigt" reads as a RELATIVE date in the - // medication-card grammar (today → "Heute", yesterday → "Gestern", else the - // absolute date) through the shared `relativeCalendarDate` helper, reusing - // the same `medications.today` / `medications.yesterday` keys the medication - // card renders. Falls back to "—" when there is no last-done value. - const lastValue = reminder.lastSatisfiedAt - ? relativeCalendarDate( - reminder.lastSatisfiedAt, - t, - (d) => fmt.date(d), - displayTz, - ) - : "—"; - - // v1.18.9 (#39) — the next-due status reads as DISCREET COLOURED TEXT (never - // a card-background tint): due today → green, overdue → orange, otherwise the - // normal muted-resolved date. The card surface stays neutral per the house - // rule; only this inline label carries the status hue. - const dueIsToday = due.key === "measurementReminders.nextDue.today"; - const dueIsOverdue = isOverdueDue(due); - const nextLastSlot = ( -
-
- - {t("measurementReminders.nextDueLabel")} - - - {t(due.key, { days: due.days })} - -
-
- - {t("measurementReminders.lastDoneLabel")} - - {lastValue} -
-
- ); - - return ( - <> - {/* NEUTRAL card — no status-driven colour anywhere on the surface or - the action button. Due state reads only through the discreet - coloured next-due text. Shares the medication card shell + tokens. */} - - - - {nextLastSlot} - {skipSnoozeMeta} - - {/* v1.18.6 — light gamification: progress through the - current interval toward next-due, reusing the medication card's - Progress building block. Honest "on track / due" signal without - a fabricated streak; hidden for RRULE / unscheduled reminders. */} - {progress != null && ( -
-
- {t("measurementReminders.adherence.label")} - - {isDue - ? t("measurementReminders.adherence.due") - : t("measurementReminders.adherence.onTrack")} - -
- -
- )} - - {reminder.endsOn && ( -

- {/* `endsOn` is a real instant (`now + courseDays` at Coach - accept, plain timestamptz — NOT a `@db.Date` UTC-midnight - calendar date), so the profile-tz formatter renders the - correct local course-end day for every zone. */} - {t("measurementReminders.until", { - date: fmt.date(new Date(reminder.endsOn)), - })} -

- )} - {reminder.location && ( -

- {t("measurementReminders.location.prefix", { - location: reminder.location, - })} -

- )} - - {/* Bottom-pinned action row: the primary action takes the width it - can, postpone sits beside it at its natural size — the same - two-button shape the medication card uses for taken / skipped. */} - {primaryButton ? ( -
-
- {primaryButton} - {postponeButton} -
- {fileVisitButton} -
- ) : null} -
-
- - {deleteDialog} - {postponeSheet} - {skipConfirmDialog} - - ); -}