mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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
Reference in new issue
Block a user