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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 12:59:49 +02:00
1 parent 25b5833b11
commit d7f0d88d59
13 files changed
+1426 -1192

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": 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,
@@ -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,
@@ -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,
@@ -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.",
},
{
@@ -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"],
},
@@ -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,
},
@@ -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",
];
@@ -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";
/**
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-lg"
className="sm:size-10"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={onEdit} disabled={busy}>
<Pencil className="mr-2 h-4 w-4" />
{t("measurementReminders.edit")}
</DropdownMenuItem>
{/* 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 && (
<DropdownMenuItem asChild>
<Link
href={`/measurements?type=${encodeURIComponent(
reminder.measurementType as string,
)}`}
>
<LineChart className="mr-2 h-4 w-4" />
{t("measurementReminders.showMeasurements")}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onSelect={onRequestDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("measurementReminders.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
/** "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 (
<div
className="text-muted-foreground space-y-0.5 text-xs"
data-slot="vorsorge-skip-snooze-meta"
>
{isSnoozed && reminder.snoozedUntil && (
<p>
{t("measurementReminders.snoozedUntil", {
date: fmt.date(new Date(reminder.snoozedUntil)),
})}
</p>
)}
{isSkippedCycle && reminder.lastSkippedAt && (
<p>
{t("measurementReminders.skippedOn", {
date: fmt.date(new Date(reminder.lastSkippedAt)),
})}
{reminder.skipCount > 1
? ` · ${t("measurementReminders.skipCount", {
count: reminder.skipCount,
})}`
: null}
</p>
)}
</div>
);
}
/**
* 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<typeof relativeDueKey>;
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 (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("measurementReminders.nextDueLabel")}
</span>
<span
className={cn(
"text-right",
dueIsToday
? "text-success font-medium"
: dueIsOverdue
? "text-warning font-medium"
: "text-foreground",
)}
>
{t(due.key, { days: due.days })}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("measurementReminders.lastDoneLabel")}
</span>
<span className="text-foreground text-right">{lastValue}</span>
</div>
</div>
);
}
/** 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<typeof relativeDueKey>;
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 (
<Card className="py-3 md:py-3">
<CardContent className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="truncate font-medium">
{resolveReminderLabel(reminder, t)}
</span>
</div>
{/* Cadence rides on the metric line, styled like the
metric text (no shaded pill). */}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground">
{t(due.key, { days: due.days })}
</span>
<span>{categoryLabel}</span>
{cadenceText ? <span>{cadenceText}</span> : null}
</div>
{meta}
</div>
<div className="flex shrink-0 items-center gap-1">
{/* 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 ? (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onPrimaryAction}
disabled={busy}
>
{isLinked
? t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.measureNow",
)
: t("measurementReminders.markDone")}
</Button>
) : 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 ? (
<Button
type="button"
variant="outline"
size="icon"
className="size-11 sm:size-9"
data-slot="vorsorge-postpone"
aria-label={t("measurementReminders.postpone.menuItem")}
title={t("measurementReminders.postpone.menuItem")}
onClick={onPostpone}
disabled={busy}
>
<CalendarClock className="h-4 w-4" aria-hidden />
</Button>
) : null}
{/* Free-text reminders only: a typed / screening reminder has no
practice visit behind it. */}
{canManageMeasurements && !isLinked ? (
<Button
type="button"
variant="outline"
size="icon"
className="size-11 sm:size-9"
data-slot="vorsorge-file-visit"
aria-label={t("measurementReminders.fileVisit")}
title={t("measurementReminders.fileVisit")}
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" aria-hidden />
</Button>
) : null}
{headerActions}
</div>
</CardContent>
</Card>
);
}
/**
* 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 ? (
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
data-slot="vorsorge-file-visit"
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" />
{t("measurementReminders.fileVisit")}
</Button>
) : null;
// Postpone beside the primary action, outline against filled, as the
// medication card carries taken and skipped.
const postponeButton = (
<Button
type="button"
variant="outline"
className="min-h-11"
data-slot="vorsorge-postpone"
onClick={onPostpone}
disabled={busy}
>
<CalendarClock className="h-4 w-4" />
{t("measurementReminders.postpone.menuItem")}
</Button>
);
const primaryButton = (
<Button
type="button"
className="min-h-11 flex-1"
onClick={onPrimaryAction}
disabled={busy}
>
{isLinked ? (
<>
<Activity className="h-4 w-4" />
{t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.measureNow",
)}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
{t("measurementReminders.markDone")}
</>
)}
</Button>
);
return (
<div className="mt-auto space-y-1.5 pt-0">
<div className="flex gap-2">
{primaryButton}
{postponeButton}
</div>
{fileVisitButton}
</div>
);
}
/**
* 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 && (
<div className="space-y-1">
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>{t("measurementReminders.adherence.label")}</span>
<span className="tabular-nums">
{isDue
? t("measurementReminders.adherence.due")
: t("measurementReminders.adherence.onTrack")}
</span>
</div>
<Progress
value={progress}
aria-label={t("measurementReminders.adherence.label")}
/>
</div>
)}
{reminder.endsOn && (
<p className="text-muted-foreground text-sm">
{/* `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)),
})}
</p>
)}
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</>
);
}
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 && (
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
)}
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</>
) : undefined;
const headerActions = canManageMeasurements ? (
<CheckupMenu
reminder={reminder}
isLinked={isLinked}
busy={busy}
onEdit={onEdit}
onRequestDelete={() => setConfirmDelete(true)}
/>
) : null;
const dialogs = (
<>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurementReminders.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurementReminders.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onRemove}>
{t("measurementReminders.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CheckupPostponeSheet
reminder={reminder}
now={now}
open={postponeOpen}
onClose={() => 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 ? (
<SkipSnoozeMeta
reminder={reminder}
isSnoozed={isSnoozed}
isSkippedCycle={isSkippedCycle}
/>
) : null;
if (view === "list") {
return (
<>
<CheckupListRow
reminder={reminder}
due={due}
categoryLabel={categoryLabel}
cadenceText={cadenceText}
meta={skipSnoozeMeta}
isLinked={isLinked}
isScreening={isScreening}
canManageMeasurements={canManageMeasurements}
busy={busy}
onPrimaryAction={onPrimaryAction}
onPostpone={() => setPostponeOpen(true)}
onFileVisit={onFileVisit}
headerActions={headerActions}
/>
{dialogs}
</>
);
}
return (
<>
<Card className="h-full gap-3 md:gap-3">
<MedicationCardHeader
name={resolveReminderLabel(reminder, t)}
dose=""
categoryLabel={categoryLabel}
metricMeta={cadenceText}
href={measurementsHref}
linkLabel={
measurementsHref
? t("measurementReminders.showMeasurements")
: undefined
}
stateBadges={stateBadges}
actions={headerActions}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
<NextLastSlot reminder={reminder} due={due} displayTz={displayTz} />
{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. */}
<CheckupDetails
reminder={reminder}
progress={progress}
isDue={isDue}
/>
{/* 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 ? (
<CheckupActionRow
isLinked={isLinked}
isScreening={isScreening}
busy={busy}
onPrimaryAction={onPrimaryAction}
onPostpone={() => setPostponeOpen(true)}
onFileVisit={onFileVisit}
/>
) : null}
</CardContent>
</Card>
{dialogs}
</>
);
}
@@ -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 (
<>
<ResponsiveSheet
open={open}
onOpenChange={(open) => {
if (!open) closePostpone();
}}
title={t("measurementReminders.postpone.title")}
description={t("measurementReminders.postpone.description")}
footer={
<>
<Button type="button" variant="outline" onClick={closePostpone}>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={!snoozeDate || busy}
onClick={() => {
onSnooze(snoozeDate);
closePostpone();
}}
>
{t("measurementReminders.postpone.confirm")}
</Button>
</>
}
>
<div className="space-y-4">
{/* Quick chips write the date field rather than firing directly, so
the person always sees (and can adjust) what will be sent. */}
<div className="flex flex-wrap gap-2">
{postponeOffsetTargets(now, displayTz).map(({ days, date }) => (
<Button
key={days}
type="button"
size="sm"
variant={snoozeDate === date ? "secondary" : "outline"}
onClick={() => setSnoozeDate(date)}
>
{t("measurementReminders.postpone.chip", { days })}
</Button>
))}
</div>
<div className="space-y-2">
<Label htmlFor={`snooze-date-${reminder.id}`}>
{t("measurementReminders.postpone.dateLabel")}
</Label>
<DateField
id={`snooze-date-${reminder.id}`}
value={snoozeDate}
onChange={setSnoozeDate}
min={minSnoozeDate}
/>
</div>
{/* 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. */}
<div className="space-y-2 border-t pt-4">
<p className="text-sm font-medium">
{t("measurementReminders.skip.heading")}
</p>
<p className="text-muted-foreground text-sm">
{t("measurementReminders.skip.explainer")}
</p>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setConfirmSkip(true)}
data-slot="vorsorge-skip"
>
{t("measurementReminders.skip.action")}
</Button>
</div>
</div>
</ResponsiveSheet>
<AlertDialog open={confirmSkip} onOpenChange={setConfirmSkip}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurementReminders.skip.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurementReminders.skip.explainer")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onSkip();
closePostpone();
}}
>
{t("measurementReminders.skip.action")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -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<SetStateAction<FormState>>;
saving: boolean;
onClose: () => void;
onSubmit: () => void;
}) {
const { t } = useTranslations();
return (
<ResponsiveSheet
open={open}
onOpenChange={(open) => {
if (!open) onClose();
}}
title={t(
isEdit
? "measurementReminders.form.editTitle"
: "measurementReminders.form.createTitle",
)}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={!form.label.trim() || saving}
>
{t("measurementReminders.form.save")}
</Button>
</>
}
>
{/* 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. */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
{t("measurementReminders.form.label")}
</Label>
<Input
id="vorsorge-label"
value={form.label}
maxLength={120}
placeholder={t("measurementReminders.form.labelPlaceholder")}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-kind">
{t("measurementReminders.form.kind")}
</Label>
<NativeSelect
id="vorsorge-kind"
value={form.kind}
onChange={(e) =>
setForm((f) => ({
...f,
kind: e.target.value as "type" | "freeText",
}))
}
>
<option value="type">
{t("measurementReminders.form.kindType")}
</option>
<option value="freeText">
{t("measurementReminders.form.kindFreeText")}
</option>
</NativeSelect>
</div>
{form.kind === "type" && (
<div className="space-y-2">
<Label htmlFor="vorsorge-type">
{t("measurementReminders.form.measurementType")}
</Label>
<NativeSelect
id="vorsorge-type"
value={form.measurementType}
onChange={(e) => {
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 }) => (
<optgroup
key={group}
label={t(`measurementReminders.typeGroups.${group}`)}
>
{types.map((type) => (
<option key={type} value={type}>
{t(`measurementReminders.types.${type}`)}
</option>
))}
</optgroup>
))}
</NativeSelect>
</div>
)}
<CadenceFields form={form} setForm={setForm} />
<div className="space-y-2">
<Label htmlFor="vorsorge-anchor">
{t("measurementReminders.form.firstDue")}
</Label>
<DateField
id="vorsorge-anchor"
value={form.anchorDate}
onChange={(value) => setForm((f) => ({ ...f, anchorDate: value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-location">
{t("measurementReminders.form.location")}
</Label>
<Input
id="vorsorge-location"
value={form.location}
maxLength={200}
placeholder={t("measurementReminders.form.locationPlaceholder")}
onChange={(e) =>
setForm((f) => ({ ...f, location: e.target.value }))
}
/>
</div>
</div>
</ResponsiveSheet>
);
}
/**
* 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<SetStateAction<FormState>>;
}) {
const { t } = useTranslations();
const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM;
const cadenceRrule = form.cadenceChoice === CADENCE_RRULE;
return (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-interval">
{t("measurementReminders.form.cadence")}
</Label>
<NativeSelect
id="vorsorge-interval"
value={form.cadenceChoice}
onChange={(e) =>
setForm((f) => ({ ...f, cadenceChoice: e.target.value }))
}
>
{INTERVAL_PRESETS.map((days) => (
<option key={days} value={String(days)}>
{t("measurementReminders.cadence.everyNDays", { days })}
</option>
))}
<option value={CADENCE_CUSTOM}>
{t("measurementReminders.cadence.customInterval")}
</option>
<option value={CADENCE_RRULE}>
{t("measurementReminders.cadence.rruleOption")}
</option>
</NativeSelect>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-hour">
{t("measurementReminders.form.notifyHour")}
</Label>
<NativeSelect
id="vorsorge-hour"
value={String(form.notifyHour)}
onChange={(e) =>
setForm((f) => ({ ...f, notifyHour: Number(e.target.value) }))
}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{`${h.toString().padStart(2, "0")}:00`}
</option>
))}
</NativeSelect>
</div>
</div>
{cadenceCustom && (
<div className="space-y-2">
<Label htmlFor="vorsorge-custom-interval">
{t("measurementReminders.form.customInterval")}
</Label>
<Input
id="vorsorge-custom-interval"
type="number"
min={1}
max={3650}
value={form.customIntervalDays}
onChange={(e) =>
setForm((f) => ({
...f,
customIntervalDays: Number(e.target.value),
}))
}
/>
</div>
)}
{cadenceRrule && (
<div className="space-y-2">
<Label htmlFor="vorsorge-rrule">
{t("measurementReminders.form.rrule")}
</Label>
<Input
id="vorsorge-rrule"
value={form.rrule}
maxLength={512}
placeholder="FREQ=YEARLY"
onChange={(e) => setForm((f) => ({ ...f, rrule: e.target.value }))}
/>
<p className="text-muted-foreground text-xs">
{t("measurementReminders.form.rruleHint")}
</p>
</div>
)}
</>
);
}
@@ -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 `<input type="date">` 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());
}
/**
* `<input type="date">` 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,
};
}
File diff suppressed because it is too large. Load diff