+ )}
+
+
+ {
+ if (!open) setCapturing(null);
+ }}
+ title={t("measurementReminders.capture.title")}
+ description={t("measurementReminders.capture.description")}
+ footer={}
+ >
+ {capturing && (
+ setCapturing(null)}
+ footerSlot={captureFooterEl}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index bc80933c7..eb814c05b 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -3,44 +3,67 @@
/**
* v1.17.1 — Vorsorge (preventive-care / measurement) reminder surface.
*
- * Answers "wann muss ich was wo machen": a list of upcoming reminders
- * sorted by server-computed next-due, each card showing the label, the
- * cadence, the location, and a relative next-due badge. Per the project
+ * Answers "wann muss ich was wo machen": a grid of upcoming reminders
+ * sorted by server-computed next-due, each rendered as its OWN card that
+ * mirrors the medication card grammar (neutral surface, header + kebab,
+ * a next/last block, and a single bottom-pinned action). Per the project
* rule the card stays NEUTRAL regardless of due state — no alarming
* red/green tint; status reads through a discreet badge only.
*
* The server is authoritative for `nextDueAt`; this component renders it
* relative to "now" but never recomputes the cadence.
*
- * v1.18.1 — the create/edit form moved into a ResponsiveSheet with a
- * pinned Cancel/Save footer (matching Labs / Illness / Medications), an
- * Edit affordance wires the existing PATCH support, an enable/disable
- * toggle drives the `enabled` flag, and a COACH-origin reminder renders a
- * neutral provenance badge + an "until " course-window line while
- * translating its i18n-key label.
+ * v1.18.2 — rebuilt to mirror the MEDICATION module:
+ * - each reminder is its own med-styled card (`VorsorgeCard`) reusing
+ * `MedicationCardHeader` / `MedicationNextLastSlot` and the
+ * `MedicationIntakeActions` bottom-pinned action treatment;
+ * - the mark-done action branches on `measurementType`: a free-text /
+ * self-planned exam keeps the silent satisfy ("Erledigt"); a
+ * measurement-linked reminder opens the real `MeasurementForm`
+ * ("Wert erfassen") so completing a BP reminder lands an actual
+ * reading, then satisfies the reminder in the same user action;
+ * - the create/edit form gains a first-due date (`anchorDate`) and a
+ * custom cadence (free "every N days" + an RFC-5545 RRULE option);
+ * - a wrench/customize affordance sits beside the page-variant Add,
+ * hosting the per-reminder enable/disable toggles (moved off the
+ * card so the card carries a single med-style kebab).
*/
import { useState } from "react";
-import { CalendarClock, CheckCircle2, Pencil, Plus } from "lucide-react";
+import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { DeleteButton } from "@/components/data-list";
+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 {
- Card,
- CardAction,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
+import { MeasurementForm } from "@/components/measurements/measurement-form";
+import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
+import { MedicationNextLastSlot } from "@/components/medications/card-parts/medication-next-last-slot";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -81,6 +104,10 @@ const TYPE_GROUPS = [
] as const;
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
+// v1.18.2 — the sentinel the cadence picker writes when the user wants a
+// free interval or an RFC-5545 RRULE instead of a fixed preset.
+const CADENCE_CUSTOM = "custom";
+const CADENCE_RRULE = "rrule";
const DAY_MS = 24 * 60 * 60 * 1000;
function relativeDueKey(
@@ -115,11 +142,34 @@ function resolveReminderLabel(
return reminder.label;
}
+/** ISO instant → the `` value (YYYY-MM-DD), local. */
+function toDateInputValue(iso: string | null): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ const offset = d.getTimezoneOffset();
+ return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
+}
+
+/** `` value → an ISO instant at local midnight, or null. */
+function fromDateInputValue(value: string): string | null {
+ if (!value) return null;
+ const d = new Date(`${value}T00:00:00`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
interface FormState {
label: string;
kind: "type" | "freeText";
measurementType: string;
- intervalDays: number;
+ /** 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;
}
@@ -128,7 +178,10 @@ const EMPTY_FORM: FormState = {
label: "",
kind: "type",
measurementType: "BLOOD_PRESSURE_SYS",
- intervalDays: 7,
+ cadenceChoice: "7",
+ customIntervalDays: 30,
+ rrule: "FREQ=YEARLY",
+ anchorDate: "",
notifyHour: 9,
location: "",
};
@@ -141,10 +194,8 @@ export function VorsorgeSection({
/**
* `"settings"` renders the compact `SettingsCardHeader` for the embedded
* settings card; `"page"` renders the canonical feature-page header
- * (`
` + subtitle + primary add button) so the standalone `/vorsorge`
- * surface matches its peers (labs, mood, medications, cycle). The
- * add-entry affordance is identical in both — a primary button with a
- * `Plus` glyph that opens the create sheet.
+ * (`
` + subtitle + primary add button + wrench) so the standalone
+ * `/vorsorge` surface matches its peers (labs, mood, medications, cycle).
*/
variant?: "settings" | "page";
}) {
@@ -158,11 +209,18 @@ export function VorsorgeSection({
null,
);
const [form, setForm] = useState(EMPTY_FORM);
+ // The customize ("wrench") sheet — re-homes the per-reminder enable
+ // toggles that no longer live on the card itself.
+ const [manageOpen, setManageOpen] = useState(false);
+ // The value-capture sheet: holds the reminder whose measurement we are
+ // entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
+ const [capturing, setCapturing] = useState(null);
+ const [captureFooterEl, setCaptureFooterEl] =
+ useState(null);
// Wall-clock anchor for the relative-due labels, captured once at mount
// via a lazy state initializer so the impure Date.now() stays out of
- // render (the repo's purity + set-state-in-effect rules both reject the
- // alternatives). The relative labels are coarse (day granularity), so a
+ // render. The relative labels are coarse (day granularity), so a
// mount-time snapshot is plenty fresh.
const [now] = useState(() => Date.now());
@@ -175,11 +233,26 @@ export function VorsorgeSection({
}
function openEdit(reminder: MeasurementReminder) {
+ // Resolve the stored cadence back onto the picker: a preset value if it
+ // matches one, otherwise the custom-interval or RRULE branch.
+ const interval = reminder.intervalDays;
+ const cadenceChoice = reminder.rrule
+ ? CADENCE_RRULE
+ : interval != null &&
+ (INTERVAL_PRESETS as readonly number[]).includes(interval)
+ ? String(interval)
+ : interval != null
+ ? CADENCE_CUSTOM
+ : "7";
setForm({
label: resolveReminderLabel(reminder, t),
kind: reminder.measurementType ? "type" : "freeText",
measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS",
- intervalDays: reminder.intervalDays ?? 7,
+ cadenceChoice,
+ customIntervalDays:
+ cadenceChoice === CADENCE_CUSTOM && interval != null ? interval : 30,
+ rrule: reminder.rrule ?? "FREQ=YEARLY",
+ anchorDate: toDateInputValue(reminder.anchorDate),
notifyHour: reminder.notifyHour,
location: reminder.location ?? "",
});
@@ -191,13 +264,32 @@ export function VorsorgeSection({
setForm(EMPTY_FORM);
}
+ // Resolve the picker state to the mutually-exclusive cadence pair the
+ // schema expects (exactly one of intervalDays / rrule).
+ function resolveCadence(): {
+ intervalDays: number | null;
+ rrule: string | null;
+ } {
+ if (form.cadenceChoice === CADENCE_RRULE) {
+ const trimmed = form.rrule.trim();
+ return { intervalDays: null, rrule: trimmed || null };
+ }
+ if (form.cadenceChoice === CADENCE_CUSTOM) {
+ return { intervalDays: form.customIntervalDays, rrule: null };
+ }
+ return { intervalDays: Number(form.cadenceChoice), rrule: null };
+ }
+
function submit() {
const trimmed = form.label.trim();
if (!trimmed) return;
+ const { intervalDays, rrule } = resolveCadence();
const body = {
label: trimmed,
measurementType: form.kind === "type" ? form.measurementType : null,
- intervalDays: form.intervalDays,
+ intervalDays,
+ rrule,
+ anchorDate: fromDateInputValue(form.anchorDate),
notifyHour: form.notifyHour,
location: form.location.trim() || null,
};
@@ -211,10 +303,31 @@ export function VorsorgeSection({
}
}
- const saving = create.isPending || update.isPending;
+ // The mark-done action. A free-text / self-planned exam satisfies
+ // silently; a measurement-linked reminder opens the real value-entry
+ // form so the user logs an actual reading.
+ function onPrimaryAction(reminder: MeasurementReminder) {
+ if (reminder.measurementType) {
+ setCapturing(reminder);
+ } else {
+ satisfy.mutate(reminder.id);
+ }
+ }
- // The shared primary add-entry affordance — identical in both variants so
- // the control reads the same on the standalone page and the settings card.
+ // On a successful measurement write, satisfy the reminder in the same
+ // user action (forward-only guard makes the later cron a no-op) and
+ // close the sheet.
+ function onCaptureSuccess() {
+ const reminder = capturing;
+ setCapturing(null);
+ if (reminder) satisfy.mutate(reminder.id);
+ }
+
+ const saving = create.isPending || update.isPending;
+ const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM;
+ const cadenceRrule = form.cadenceChoice === CADENCE_RRULE;
+
+ // The shared primary add-entry affordance — identical in both variants.
const addButton = (
- {addButton}
+
+ {addButton}
+ {wrenchButton}
+
) : (
{/* v1.18.1 — the cadence/type/hour pickers use NativeSelect (not the
Radix Select labs/illness use) deliberately: these are long,
- grouped, scalar option lists (24 hours, a 15-type grouped metric
- list) where the OS-native picker is faster on touch and avoids a
- tall scrolling Radix popover inside the bottom-sheet. The form
- fields below stay NativeSelect by design, not drift. */}
+ 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
+ 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. */}
+ {
+ if (!open) setCapturing(null);
+ }}
+ title={t("measurementReminders.capture.title")}
+ description={t("measurementReminders.capture.description")}
+ footer={}
+ >
+ {capturing && (
+ setCapturing(null)}
+ footerSlot={captureFooterEl}
+ />
+ )}
+
+
+ {/* v1.18.2 — customize sheet: re-homes the per-reminder enable/disable
+ toggles (moved off the card so it carries a single med-style kebab). */}
+
+ {(reminders?.length ?? 0) === 0 ? (
+
+ )}
+
+ {/* Bottom-pinned single primary action — branches on the link state:
+ a self-planned exam marks done; a measurement-linked reminder
+ opens the value-entry form. */}
+