+ {/* The same gate the cards branch applies through
+ `primaryButton`. The list branch used to inline its own
+ ungated copy, and which branch a person sees is a per-browser
+ preference that survives the switch — so the identical action
+ was offered or withheld depending on a view toggle. */}
+ {canManageMeasurements ? (
+
+ ) : null}
+ {/* Icon-only in the dense list, labelled in the cards branch —
+ the same action either way, never one branch offering what
+ the other withholds. */}
+ {canManageMeasurements ? (
+
+ ) : null}
+ {/* Free-text reminders only: a typed / screening reminder has no
+ practice visit behind it. */}
+ {canManageMeasurements && !isLinked ? (
+
+ ) : null}
+ {headerActions}
+
+
+
+ );
+}
+
+/**
+ * The bottom-pinned action row (owner only): the primary action takes the
+ * width it can, postpone sits beside it, as the medication card carries taken
+ * and skipped; a free-text checkup adds "file a visit" below.
+ */
+function CheckupActionRow({
+ isLinked,
+ isScreening,
+ busy,
+ onPrimaryAction,
+ onPostpone,
+ onFileVisit,
+}: {
+ isLinked: boolean;
+ isScreening: boolean;
+ busy: boolean;
+ onPrimaryAction: () => void;
+ onPostpone: () => void;
+ onFileVisit: () => void;
+}) {
+ const { t } = useTranslations();
+ // "File a visit": the second way to close a checkup, beside "done". Only a
+ // free-text reminder (a real practice visit) carries it; a typed one is
+ // satisfied by a reading or a check-in. Saving the visit as DONE satisfies
+ // the reminder through the same forward-only, idempotent path, so doing
+ // both is one satisfaction.
+ const fileVisitButton = !isLinked ? (
+
+ ) : null;
+
+ // Postpone beside the primary action, outline against filled, as the
+ // medication card carries taken and skipped.
+ const postponeButton = (
+
+ );
+
+ const primaryButton = (
+
+ );
+
+ return (
+
+
+ {primaryButton}
+ {postponeButton}
+
+ {fileVisitButton}
+
+ );
+}
+
+/**
+ * The card's body lines: progress through the interval (hidden for RRULE or
+ * unscheduled reminders), the course end and the location.
+ */
+function CheckupDetails({
+ reminder,
+ progress,
+ isDue,
+}: {
+ reminder: MeasurementReminder;
+ progress: number | null;
+ isDue: boolean;
+}) {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ return (
+ <>
+ {progress != null && (
+
+ {/* `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)),
+ })}
+
+ {/* The honest-skip zone sits set apart below the snooze controls:
+ a different decision (let this cycle go, restart the rhythm)
+ with its own confirm step. */}
+
+
+ {t("measurementReminders.skip.heading")}
+
+
+ {t("measurementReminders.skip.explainer")}
+
+
+
+
+
+
+
+
+
+ {t("measurementReminders.skip.confirmTitle")}
+
+
+ {t("measurementReminders.skip.explainer")}
+
+
+
+ {t("common.cancel")}
+ {
+ onSkip();
+ closePostpone();
+ }}
+ >
+ {t("measurementReminders.skip.action")}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/measurement-reminders/checkups/reminder-form-sheet.tsx b/src/components/measurement-reminders/checkups/reminder-form-sheet.tsx
new file mode 100644
index 000000000..4678e58e9
--- /dev/null
+++ b/src/components/measurement-reminders/checkups/reminder-form-sheet.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import type { Dispatch, SetStateAction } from "react";
+
+import { Button } from "@/components/ui/button";
+import { DateField } from "@/components/ui/date-field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { useTranslations } from "@/lib/i18n/context";
+import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
+
+import {
+ CADENCE_CUSTOM,
+ CADENCE_RRULE,
+ INTERVAL_PRESETS,
+ MENTAL_SCREENING_DEFAULT_INTERVAL,
+ TYPE_GROUPS,
+ type FormState,
+} from "./reminder-form";
+
+/**
+ * The create / edit sheet: label, kind, measurement type, cadence (preset,
+ * free interval or RRULE), notification hour, first due date and location.
+ */
+export function ReminderFormSheet({
+ open,
+ isEdit,
+ form,
+ setForm,
+ saving,
+ onClose,
+ onSubmit,
+}: {
+ open: boolean;
+ isEdit: boolean;
+ form: FormState;
+ setForm: Dispatch>;
+ saving: boolean;
+ onClose: () => void;
+ onSubmit: () => void;
+}) {
+ const { t } = useTranslations();
+ return (
+ {
+ if (!open) onClose();
+ }}
+ title={t(
+ isEdit
+ ? "measurementReminders.form.editTitle"
+ : "measurementReminders.form.createTitle",
+ )}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+ {/* The cadence/type/hour pickers use NativeSelect (not the
+ Radix Select labs/illness use) deliberately: these are long,
+ grouped, scalar option lists where the OS-native picker is faster
+ on touch and avoids a tall scrolling Radix popover inside the
+ bottom-sheet. The form fields stay NativeSelect by design. */}
+
-
-
- {/* v1.18.2 — value-capture sheet: a measurement-linked reminder opens
+ {/* Value-capture sheet: a measurement-linked reminder opens
the real MeasurementForm pre-filled with its type, so completing a
BP reminder logs an actual reading. On success the reminder is
satisfied in the same action. */}
@@ -840,697 +393,3 @@ export function VorsorgeSection({
);
}
-
-/**
- * v1.18.6 — progress through the current interval toward the next
- * due date, as a 0–100 percentage. Honest "are we on track" gamification
- * that reuses the medication card's `Progress` building block without
- * fabricating a streak the reminder data cannot support: 100 % (or beyond)
- * means due/overdue now, lower means time still remains in the window.
- * Returns null when there is no interval to measure against (RRULE / unset).
- */
-/** v1.37.20 (#223) — the offsets the postpone menu offers, in days. */
-const SNOOZE_CHIP_DAYS = [7, 30, 90] as const;
-
-/**
- * The local calendar day (ISO `yyyy-MM-dd`) an instant falls on in `tz`.
- * Snooze is day-precision by contract: the server resolves the day to the
- * reminder's notifyHour in the profile timezone.
- */
-function isoDayInTz(atMs: number, tz: string): string {
- return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz });
-}
-
-/**
- * The dates the postpone sheet's quick chips resolve to, on the display zone's
- * calendar.
- *
- * Its own function because a chip labelled "+7 days" has to write the day that
- * is actually seven days out, including across a DST shift, where adding
- * 7 × 86 400 000 ms lands an hour either side of midnight and so can write the
- * neighbouring date.
- */
-export function postponeOffsetTargets(
- nowMs: number,
- tz: string,
-): { days: number; date: string }[] {
- // Count days on the calendar, not in milliseconds: `now + 7 * DAY_MS` keeps
- // the wall-clock time only where the offset stays constant, and a DST shift
- // in between moves it an hour, which flips the date for anyone acting near
- // midnight. Anchoring today's local date at UTC noon and stepping whole
- // UTC days keeps every offset on the day its label names.
- const today = isoDayInTz(nowMs, tz);
- return SNOOZE_CHIP_DAYS.map((days) => ({
- days,
- date: shiftDateKey(today, days),
- }));
-}
-
-function intervalProgress(
- reminder: MeasurementReminder,
- now: number,
-): number | null {
- if (reminder.intervalDays == null || reminder.nextDueAt == null) return null;
- const dueMs = new Date(reminder.nextDueAt).getTime();
- if (Number.isNaN(dueMs)) return null;
- const windowMs = reminder.intervalDays * DAY_MS;
- const startMs = dueMs - windowMs;
- const elapsed = now - startMs;
- return Math.max(0, Math.min(100, Math.round((elapsed / windowMs) * 100)));
-}
-
-function VorsorgeCard({
- reminder,
- now,
- view,
- onPrimaryAction,
- onFileVisit,
- onRemove,
- onEdit,
- onSkip,
- onSnooze,
- busy,
-}: {
- reminder: MeasurementReminder;
- now: number;
- view: "cards" | "list";
- onPrimaryAction: () => void;
- /** Open the visit sheet with this checkup pre-filled, closing it on save. */
- onFileVisit: () => void;
- onRemove: () => void;
- onEdit: () => void;
- /** v1.37.20 (#223) — honest skip; the server restarts the interval. */
- onSkip: () => void;
- /** v1.37.20 (#223) — snooze to a calendar day (ISO YYYY-MM-DD). */
- onSnooze: (until: string) => void;
- busy: boolean;
-}) {
- const { t } = useTranslations();
- const fmt = useFormatters();
- const { canManageDomain } = useRecordCapabilities();
- const canManageMeasurements = canManageDomain("measurements");
- // Issue #490 — day-boundary zone for the relative "today / yesterday"
- // bucket must match the zone `fmt.date` renders in (mirror → Berlin).
- // The next-due phrase reads off the SAME zone as the last-done date below
- // it; they used to disagree by a day for anyone whose device sat in another
- // zone from their profile.
- const displayTz = useDisplayTimezone();
- const [confirmDelete, setConfirmDelete] = useState(false);
- // v1.37.20 (#223) — the postpone sheet (snooze quick-chips + date field,
- // with the honest-skip zone below) and the skip confirm inside it.
- const [postponeOpen, setPostponeOpen] = useState(false);
- const [snoozeDate, setSnoozeDate] = useState("");
- const [confirmSkip, setConfirmSkip] = useState(false);
- const due = relativeDueKey(reminder.nextDueAt, now, displayTz);
- const cadence =
- reminder.intervalDays != null
- ? t("measurementReminders.cadence.everyNDays", {
- days: reminder.intervalDays,
- })
- : reminder.rrule
- ? t("measurementReminders.cadence.custom")
- : "";
- const isCoach = reminder.origin === "COACH";
- const isLinked = reminder.measurementType != null;
- // v1.27.5 — due-ness never changes the ACTION BUTTON: it keeps one
- // constant look in every state (same rule as the medication card's
- // constant surface). Due / overdue reads only through the discreet
- // coloured next-due text + the adherence meta line below.
- // v1.27.6 — a screening reminder's action is "start the check-in" (it
- // routes to the check-in page), never the numeric value-entry wording.
- const isScreening = isScreeningReminderType(reminder.measurementType);
- const isDue =
- due.key === "measurementReminders.nextDue.today" || isOverdueDue(due);
- const progress = intervalProgress(reminder, now);
-
- // Category-style header badge: the measurement label, or "self-planned"
- // for a free-text exam — mirroring the medication card's class badge.
- const categoryLabel = isLinked
- ? t(`measurementReminders.types.${reminder.measurementType}`)
- : t("measurementReminders.selfPlanned");
-
- // v1.18.10 — the cadence reads as part of the METRIC ROW (the category
- // line), styled like the metric label itself — same muted weight/colour,
- // no shaded pill. The card heading stays clean (nothing beside it); the
- // cadence rides next to "Blutdruck" et al. via `metricMeta` below.
- const cadenceText = cadence || null;
-
- // v1.18.10 — a measurement-linked reminder's heading links to the
- // measurements history pre-filtered to its type (the same target the
- // "Messergebnisse anzeigen" kebab item uses). A free-text reminder has no
- // readings to show, so it stays a plain (non-linking) heading.
- const measurementsHref = isLinked
- ? `/measurements?type=${encodeURIComponent(reminder.measurementType as string)}`
- : undefined;
-
- // Provenance / disabled state badges keep their own row below the category.
- // Omit the row entirely when neither applies (don't render an empty band).
- const hasStateBadges = isCoach || !reminder.enabled;
- const stateBadges = hasStateBadges ? (
- <>
- {isCoach && (
- {t("measurementReminders.originCoach")}
- )}
- {!reminder.enabled && (
-
- {t("measurementReminders.disabledBadge")}
-
- )}
- >
- ) : undefined;
-
- // Single med-style kebab: Edit + Delete. The Delete item is the shared
- // confirm-on-delete control rendered as a full-width menu row.
- const headerActions = canManageMeasurements ? (
-
-
-
-
-
-
-
- {t("measurementReminders.edit")}
-
- {/* v1.37.20 (#223) put postpone in the kebab. It reads better beside
- the primary action, the way the medication card carries "taken"
- and "skipped" side by side, so it is a card button now — see
- `postponeButton` below. */}
- {/* v1.18.7 — jump to the measurements list pre-filtered to
- this reminder's type. Only for measurement-linked reminders; a
- free-text reminder has no readings to show. */}
- {isLinked && (
-
-
-
- {t("measurementReminders.showMeasurements")}
-
-
- )}
-
- setConfirmDelete(true)}
- >
-
- {t("measurementReminders.delete")}
-
-
-
- ) : null;
-
- // The measure / mark-done action. One constant appearance in every state —
- // no due-driven tint, no label swap (v1.27.5 retired the former green
- // "do it now" wash; a button that changes colour by schedule state reads as
- // a different control). Due-ness is communicated by the discreet coloured
- // next-due text above.
- /**
- * "Termin eintragen" — the second way to close a checkup.
- *
- * Beside "erledigt" rather than instead of it, because the two answer
- * different questions: one says the thing happened, the other says WHERE it
- * happened and files the record of it. Saving the visit as DONE satisfies
- * this reminder through the same forward-only, idempotent primitive the
- * checkbox uses, so a person who does both gets one satisfaction and no
- * error.
- */
- // The file-visit action belongs only to a free-text reminder — a real
- // practice visit, where "you went" is recorded by filing a visit. A typed
- // reminder (Gewicht, Blutdruck, a screening check-in) is satisfied by a
- // reading or a check-in inside the app, so it carries no visit affordance.
- // `resolve.ts` states the same boundary server-side: an appointment reminder
- // is free-text by construction.
- const fileVisitButton =
- canManageMeasurements && !isLinked ? (
-
- ) : null;
-
- // Postponing sits beside the primary action rather than inside the kebab,
- // the way the medication card carries "taken" and "skipped" as two buttons
- // on the card. Outline against the filled primary: the same pairing, and
- // the same rule as everything else in this bar — one constant look, never
- // tinted by how overdue the checkup is.
- const postponeButton = canManageMeasurements ? (
-
- ) : null;
-
- const primaryButton = canManageMeasurements ? (
-
- ) : null;
-
- const deleteDialog = (
-
-
-
-
- {t("measurementReminders.deleteConfirmTitle")}
-
-
- {t("measurementReminders.deleteConfirmDescription")}
-
-
-
- {t("common.cancel")}
-
- {t("measurementReminders.delete")}
-
-
-
-
- );
-
- // v1.37.20 (#223) — resolved skip/snooze state, clock comparisons only
- // (the DTO comment states the contract: the server resolves, the client
- // compares). Meta lines are muted text — never a card colour change.
- const isSnoozed =
- reminder.snoozedUntil != null &&
- new Date(reminder.snoozedUntil).getTime() > now;
- const isSkippedCycle =
- reminder.lastSkippedAt != null &&
- (reminder.lastSatisfiedAt == null ||
- new Date(reminder.lastSkippedAt).getTime() >
- new Date(reminder.lastSatisfiedAt).getTime());
-
- const closePostpone = () => {
- setPostponeOpen(false);
- setSnoozeDate("");
- setConfirmSkip(false);
- };
- const minSnoozeDate = isoDayInTz(now + DAY_MS, displayTz);
-
- const postponeSheet = (
- {
- if (!open) closePostpone();
- }}
- title={t("measurementReminders.postpone.title")}
- description={t("measurementReminders.postpone.description")}
- footer={
- <>
-
-
- >
- }
- >
-
- {/* Quick chips write the date field rather than firing directly, so
- the person always sees (and can adjust) what will be sent. */}
-
- {/* The honest-skip zone sits set apart below the snooze controls:
- a different decision (let this cycle go, restart the rhythm)
- with its own confirm step. */}
-
-
- {t("measurementReminders.skip.heading")}
-
-
- {t("measurementReminders.skip.explainer")}
-
-
-
-
-
- );
-
- const skipConfirmDialog = (
-
-
-
-
- {t("measurementReminders.skip.confirmTitle")}
-
-
- {t("measurementReminders.skip.explainer")}
-
-
-
- {t("common.cancel")}
- {
- onSkip();
- closePostpone();
- }}
- >
- {t("measurementReminders.skip.action")}
-
-
-
-
- );
-
- // Muted meta lines shared by both branches: "pushed back until {date}" /
- // "skipped on {date}" (+ lifetime count once it says something). "Last
- // done" stays lastSatisfiedAt — a skip never reads as done.
- const skipSnoozeMeta =
- isSnoozed || isSkippedCycle ? (
-
- {/* The same gate the cards branch applies through
- `primaryButton`. The list branch used to inline its own
- ungated copy, and which branch a person sees is a per-browser
- preference that survives the switch — so the identical action
- was offered or withheld depending on a view toggle. */}
- {canManageMeasurements ? (
-
- ) : null}
- {/* Icon-only in the dense list, labelled in the cards branch —
- the same action either way, never one branch offering what
- the other withholds. */}
- {canManageMeasurements ? (
-
- ) : null}
- {/* Free-text reminders only: a typed / screening reminder has no
- practice visit behind it. */}
- {canManageMeasurements && !isLinked ? (
-
- ) : null}
- {headerActions}
-
-
-
- {deleteDialog}
- {postponeSheet}
- {skipConfirmDialog}
- >
- );
- }
-
- // Next/last block in the med-card grammar, but with MEASUREMENT wording
- // (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The
- // "next" value carries the discreet neutral due badge; the "last" value is
- // the last satisfied date.
- // v1.18.9 (#39) — "zuletzt erledigt" reads as a RELATIVE date in the
- // medication-card grammar (today → "Heute", yesterday → "Gestern", else the
- // absolute date) through the shared `relativeCalendarDate` helper, reusing
- // the same `medications.today` / `medications.yesterday` keys the medication
- // card renders. Falls back to "—" when there is no last-done value.
- const lastValue = reminder.lastSatisfiedAt
- ? relativeCalendarDate(
- reminder.lastSatisfiedAt,
- t,
- (d) => fmt.date(d),
- displayTz,
- )
- : "—";
-
- // v1.18.9 (#39) — the next-due status reads as DISCREET COLOURED TEXT (never
- // a card-background tint): due today → green, overdue → orange, otherwise the
- // normal muted-resolved date. The card surface stays neutral per the house
- // rule; only this inline label carries the status hue.
- const dueIsToday = due.key === "measurementReminders.nextDue.today";
- const dueIsOverdue = isOverdueDue(due);
- const nextLastSlot = (
-
- );
-
- return (
- <>
- {/* NEUTRAL card — no status-driven colour anywhere on the surface or
- the action button. Due state reads only through the discreet
- coloured next-due text. Shares the medication card shell + tokens. */}
-
-
-
- {nextLastSlot}
- {skipSnoozeMeta}
-
- {/* v1.18.6 — light gamification: progress through the
- current interval toward next-due, reusing the medication card's
- Progress building block. Honest "on track / due" signal without
- a fabricated streak; hidden for RRULE / unscheduled reminders. */}
- {progress != null && (
-
- {/* `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)),
- })}
-
- )}
-
- {/* Bottom-pinned action row: the primary action takes the width it
- can, postpone sits beside it at its natural size — the same
- two-button shape the medication card uses for taken / skipped. */}
- {primaryButton ? (
-