diff --git a/messages/de.json b/messages/de.json index fdc712ad2..7b3ec3201 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6405,7 +6405,9 @@ "overdueByDays": "Überfällig seit {days} Tagen", "cadence": { "everyNDays": "Alle {days} Tage", - "custom": "Eigener Zeitplan" + "custom": "Eigener Zeitplan", + "customInterval": "Individuell (alle N Tage)", + "rruleOption": "Eigene Regel (RRULE)" }, "location": { "prefix": "Bei: {location}" @@ -6448,7 +6450,11 @@ "locationPlaceholder": "z. B. Hausarzt, Labor", "save": "Speichern", "createTitle": "Neue Erinnerung", - "editTitle": "Erinnerung bearbeiten" + "editTitle": "Erinnerung bearbeiten", + "customInterval": "Wiederholen alle (Tage)", + "rrule": "Wiederholungsregel (RRULE)", + "rruleHint": "RFC-5545, z. B. FREQ=YEARLY für eine jährliche Untersuchung.", + "firstDue": "Erste Fälligkeit (optional)" }, "pushTitle": "Vorsorge-Erinnerung", "pushBody": "Fällig: {label}", @@ -6456,7 +6462,23 @@ "edit": "Bearbeiten", "originCoach": "Coach", "enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren", - "until": "Bis {date}" + "until": "Bis {date}", + "selfPlanned": "Selbstgeplant", + "captureValue": "Wert erfassen", + "capture": { + "title": "Messwert erfassen", + "description": "Wert eintragen; die Erinnerung wird als erledigt markiert." + }, + "manage": { + "menuItem": "Anpassen", + "title": "Erinnerungen anpassen", + "description": "Einzelne Erinnerungen aktivieren oder deaktivieren.", + "empty": "Noch keine Erinnerungen zum Anpassen." + }, + "dashboard": { + "empty": "Keine anstehenden Vorsorge-Erinnerungen.", + "openLink": "Vorsorge öffnen" + } }, "labs": { "title": "Laborwerte", diff --git a/messages/en.json b/messages/en.json index 3c9a8efb3..acc413ff5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6405,7 +6405,9 @@ "overdueByDays": "Overdue by {days} days", "cadence": { "everyNDays": "Every {days} days", - "custom": "Custom schedule" + "custom": "Custom schedule", + "customInterval": "Custom (every N days)", + "rruleOption": "Custom rule (RRULE)" }, "location": { "prefix": "At: {location}" @@ -6448,7 +6450,11 @@ "locationPlaceholder": "e.g. GP, Lab", "save": "Save", "createTitle": "New reminder", - "editTitle": "Edit reminder" + "editTitle": "Edit reminder", + "customInterval": "Repeat every (days)", + "rrule": "Recurrence rule (RRULE)", + "rruleHint": "RFC-5545, e.g. FREQ=YEARLY for an annual check.", + "firstDue": "First due (optional)" }, "pushTitle": "Preventive-care reminder", "pushBody": "Time for: {label}", @@ -6456,7 +6462,23 @@ "edit": "Edit", "originCoach": "Coach", "enabledToggleAria": "Enable or disable this reminder", - "until": "Until {date}" + "until": "Until {date}", + "selfPlanned": "Self-planned", + "captureValue": "Log value", + "capture": { + "title": "Log measurement", + "description": "Enter the reading; the reminder is marked done." + }, + "manage": { + "menuItem": "Customize", + "title": "Customize reminders", + "description": "Enable or disable individual reminders.", + "empty": "No reminders to customize yet." + }, + "dashboard": { + "empty": "No upcoming preventive-care reminders.", + "openLink": "Open preventive care" + } }, "labs": { "title": "Lab results", diff --git a/messages/es.json b/messages/es.json index 240620c60..6b52dc580 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6405,7 +6405,9 @@ "overdueByDays": "Vencido hace {days} días", "cadence": { "everyNDays": "Cada {days} días", - "custom": "Programación personalizada" + "custom": "Programación personalizada", + "customInterval": "Personalizado (cada N días)", + "rruleOption": "Regla propia (RRULE)" }, "location": { "prefix": "En: {location}" @@ -6448,7 +6450,11 @@ "locationPlaceholder": "p. ej. Médico, Laboratorio", "save": "Guardar", "createTitle": "Nuevo recordatorio", - "editTitle": "Editar recordatorio" + "editTitle": "Editar recordatorio", + "customInterval": "Repetir cada (días)", + "rrule": "Regla de recurrencia (RRULE)", + "rruleHint": "RFC-5545, p. ej. FREQ=YEARLY para una revisión anual.", + "firstDue": "Primer vencimiento (opcional)" }, "pushTitle": "Recordatorio de prevención", "pushBody": "Toca: {label}", @@ -6456,7 +6462,23 @@ "edit": "Editar", "originCoach": "Coach", "enabledToggleAria": "Activar o desactivar este recordatorio", - "until": "Hasta {date}" + "until": "Hasta {date}", + "selfPlanned": "Autoplanificado", + "captureValue": "Registrar valor", + "capture": { + "title": "Registrar medición", + "description": "Introduce el valor; el recordatorio se marca como hecho." + }, + "manage": { + "menuItem": "Personalizar", + "title": "Personalizar recordatorios", + "description": "Activa o desactiva recordatorios individuales.", + "empty": "Aún no hay recordatorios para personalizar." + }, + "dashboard": { + "empty": "No hay recordatorios de prevención próximos.", + "openLink": "Abrir prevención" + } }, "labs": { "title": "Resultados de análisis", diff --git a/messages/fr.json b/messages/fr.json index a628cf66e..ce784865e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6405,7 +6405,9 @@ "overdueByDays": "En retard de {days} jours", "cadence": { "everyNDays": "Tous les {days} jours", - "custom": "Planning personnalisé" + "custom": "Planning personnalisé", + "customInterval": "Personnalisé (tous les N jours)", + "rruleOption": "Règle personnalisée (RRULE)" }, "location": { "prefix": "Chez : {location}" @@ -6448,7 +6450,11 @@ "locationPlaceholder": "ex. Médecin, Laboratoire", "save": "Enregistrer", "createTitle": "Nouveau rappel", - "editTitle": "Modifier le rappel" + "editTitle": "Modifier le rappel", + "customInterval": "Répéter tous les (jours)", + "rrule": "Règle de récurrence (RRULE)", + "rruleHint": "RFC-5545, p. ex. FREQ=YEARLY pour un contrôle annuel.", + "firstDue": "Première échéance (facultatif)" }, "pushTitle": "Rappel de prévention", "pushBody": "C'est l'heure : {label}", @@ -6456,7 +6462,23 @@ "edit": "Modifier", "originCoach": "Coach", "enabledToggleAria": "Activer ou désactiver ce rappel", - "until": "Jusqu'au {date}" + "until": "Jusqu'au {date}", + "selfPlanned": "Auto-planifié", + "captureValue": "Enregistrer la valeur", + "capture": { + "title": "Enregistrer une mesure", + "description": "Saisissez la valeur ; le rappel est marqué comme fait." + }, + "manage": { + "menuItem": "Personnaliser", + "title": "Personnaliser les rappels", + "description": "Activer ou désactiver des rappels individuels.", + "empty": "Aucun rappel à personnaliser pour l'instant." + }, + "dashboard": { + "empty": "Aucun rappel de prévention à venir.", + "openLink": "Ouvrir la prévention" + } }, "labs": { "title": "Résultats d'analyses", diff --git a/messages/it.json b/messages/it.json index 21baf7519..ffa5817d9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6405,7 +6405,9 @@ "overdueByDays": "In ritardo di {days} giorni", "cadence": { "everyNDays": "Ogni {days} giorni", - "custom": "Pianificazione personalizzata" + "custom": "Pianificazione personalizzata", + "customInterval": "Personalizzato (ogni N giorni)", + "rruleOption": "Regola personalizzata (RRULE)" }, "location": { "prefix": "Presso: {location}" @@ -6448,7 +6450,11 @@ "locationPlaceholder": "es. Medico, Laboratorio", "save": "Salva", "createTitle": "Nuovo promemoria", - "editTitle": "Modifica promemoria" + "editTitle": "Modifica promemoria", + "customInterval": "Ripeti ogni (giorni)", + "rrule": "Regola di ricorrenza (RRULE)", + "rruleHint": "RFC-5545, es. FREQ=YEARLY per un controllo annuale.", + "firstDue": "Prima scadenza (facoltativo)" }, "pushTitle": "Promemoria di prevenzione", "pushBody": "È ora di: {label}", @@ -6456,7 +6462,23 @@ "edit": "Modifica", "originCoach": "Coach", "enabledToggleAria": "Attiva o disattiva questo promemoria", - "until": "Fino al {date}" + "until": "Fino al {date}", + "selfPlanned": "Autopianificato", + "captureValue": "Registra valore", + "capture": { + "title": "Registra misurazione", + "description": "Inserisci il valore; il promemoria viene segnato come fatto." + }, + "manage": { + "menuItem": "Personalizza", + "title": "Personalizza promemoria", + "description": "Attiva o disattiva singoli promemoria.", + "empty": "Nessun promemoria da personalizzare." + }, + "dashboard": { + "empty": "Nessun promemoria di prevenzione in arrivo.", + "openLink": "Apri prevenzione" + } }, "labs": { "title": "Risultati degli esami", diff --git a/messages/pl.json b/messages/pl.json index a9302ce93..b2f7a9f2c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6405,7 +6405,9 @@ "overdueByDays": "Zaległe od {days} dni", "cadence": { "everyNDays": "Co {days} dni", - "custom": "Własny harmonogram" + "custom": "Własny harmonogram", + "customInterval": "Niestandardowe (co N dni)", + "rruleOption": "Własna reguła (RRULE)" }, "location": { "prefix": "U: {location}" @@ -6448,7 +6450,11 @@ "locationPlaceholder": "np. Lekarz, Laboratorium", "save": "Zapisz", "createTitle": "Nowe przypomnienie", - "editTitle": "Edytuj przypomnienie" + "editTitle": "Edytuj przypomnienie", + "customInterval": "Powtarzaj co (dni)", + "rrule": "Reguła powtarzania (RRULE)", + "rruleHint": "RFC-5545, np. FREQ=YEARLY dla corocznego badania.", + "firstDue": "Pierwszy termin (opcjonalnie)" }, "pushTitle": "Przypomnienie profilaktyczne", "pushBody": "Czas na: {label}", @@ -6456,7 +6462,23 @@ "edit": "Edytuj", "originCoach": "Coach", "enabledToggleAria": "Włącz lub wyłącz to przypomnienie", - "until": "Do {date}" + "until": "Do {date}", + "selfPlanned": "Zaplanowane samodzielnie", + "captureValue": "Zapisz wartość", + "capture": { + "title": "Zapisz pomiar", + "description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane." + }, + "manage": { + "menuItem": "Dostosuj", + "title": "Dostosuj przypomnienia", + "description": "Włącz lub wyłącz poszczególne przypomnienia.", + "empty": "Brak przypomnień do dostosowania." + }, + "dashboard": { + "empty": "Brak nadchodzących przypomnień profilaktycznych.", + "openLink": "Otwórz profilaktykę" + } }, "labs": { "title": "Wyniki badań", diff --git a/src/app/page.tsx b/src/app/page.tsx index 5808a63a6..c58eca290 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -55,6 +55,7 @@ import { GettingStartedChecklist } from "@/components/onboarding/getting-started import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; +import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; // v1.4.40 W-RSC — module-scope so the option object is stable across // renders (audit-M2). Pre-fix the same literal was declared inside the @@ -201,6 +202,8 @@ const CHART_CAPABLE_WIDGET_IDS = new Set([ "sleep", "steps", "medications", + // v1.18.2 — Vorsorge preventive-care summary card (chart-row only). + "vorsorge", ]); /** @@ -540,6 +543,9 @@ export default function DashboardPage() { const showSleepChart = isChartVisible("sleep") && hasSleep; const showStepsChart = isChartVisible("steps") && hasSteps; const showMedicationsCard = isChartVisible("medications"); + // v1.18.2 — Vorsorge summary card on the chart row (opt-in). Always-on + // data surface, so it gates on the layout toggle alone. + const showVorsorgeCard = isChartVisible("vorsorge"); // `layoutData` is undefined until the real layout (snapshot or legacy // widgets) resolves; `resolveDashboardLayout(undefined)` falls back to // DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default. @@ -1404,6 +1410,17 @@ export default function DashboardPage() { ), }); } + if (showVorsorgeCard) { + // v1.18.2 — Vorsorge preventive-care summary card. Slotted via + // the layout `order` like medications; self-fetches its reminder + // list and self-skeletons, so it stays out of the reveal gate + // (no chart-shaped data-ready signal). + charts.push({ + id: "vorsorge", + order: widgetOrder("vorsorge"), + node: , + }); + } if (showAchievementsCard) { // v1.4.15 phase-B4 — slotted at the user's chosen position via // the layout `order`. Default order from diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index ef50d4dd0..bc60d3b41 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -73,6 +73,59 @@ describe(" loading + empty", () => { expect(html).not.toContain('data-slot="vorsorge-loading"'); }); + it("renders a 'Log value' action for a measurement-linked reminder", () => { + // v1.18.2 — a typed reminder's primary action opens the value-entry + // form, surfaced as a "Log value" button (not a silent checkmark). + const reminder: MeasurementReminder = { + id: "linked", + label: "Measure blood pressure", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + nextDueAt: null, + notifyHour: 9, + location: null, + lastSatisfiedAt: null, + enabled: true, + createdAt: "2030-01-01T00:00:00.000Z", + updatedAt: "2030-01-01T00:00:00.000Z", + } as MeasurementReminder; + remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); + const html = render(); + expect(html).toContain("Log value"); + expect(html).not.toContain(">Done<"); + }); + + it("renders a 'Done' action for a free-text / self-planned reminder", () => { + // v1.18.2 — a free-text reminder keeps the silent satisfy, surfaced as + // a "Done" button + the "Self-planned" category badge. + const reminder: MeasurementReminder = { + id: "planned", + label: "Annual physical", + measurementType: null, + intervalDays: 365, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + nextDueAt: null, + notifyHour: 9, + location: null, + lastSatisfiedAt: null, + enabled: true, + createdAt: "2030-01-01T00:00:00.000Z", + updatedAt: "2030-01-01T00:00:00.000Z", + } as MeasurementReminder; + remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); + const html = render(); + expect(html).toContain("Done"); + expect(html).toContain("Self-planned"); + expect(html).not.toContain("Log value"); + }); + it("translates a COACH-origin label i18n key and shows the neutral badge", () => { const reminder: MeasurementReminder = { id: "c1", diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx new file mode 100644 index 000000000..817439aab --- /dev/null +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -0,0 +1,179 @@ +"use client"; + +/** + * v1.18.2 — Vorsorge (preventive-care) dashboard summary card. + * + * A compact chart-row card listing the next few due reminders with an + * inline primary action per row — the same done-vs-capture branch the + * dedicated `/vorsorge` page surfaces, scaled down for the dashboard. + * A free-text / self-planned exam marks done silently; a measurement- + * linked reminder opens the real measurement-entry form and satisfies + * the reminder in the same action. + * + * Mirrors the medication compliance card's role on the chart row: + * chart-row only, no strip tile. Self-skeletons while the read is in + * flight and self-gates to a brief empty state otherwise. + */ +import { useState } from "react"; +import Link from "next/link"; +import { CalendarClock, CheckCircle2, Plus } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MeasurementForm } from "@/components/measurements/measurement-form"; +import { + useMeasurementReminders, + useMeasurementReminderMutations, + type MeasurementReminder, +} from "@/hooks/use-measurement-reminders"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const SUMMARY_LIMIT = 3; + +function relativeDueKey( + nextDueAt: string | null, + now: number, +): { key: string; days: number } { + if (!nextDueAt) return { key: "nextDue.none", days: 0 }; + const deltaDays = Math.round((new Date(nextDueAt).getTime() - now) / DAY_MS); + if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) }; + if (deltaDays === 0) return { key: "nextDue.today", days: 0 }; + if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 }; + return { key: "nextDue.inDays", days: deltaDays }; +} + +function resolveLabel( + reminder: MeasurementReminder, + t: (key: string) => string, +): string { + if (reminder.origin === "COACH") { + const translated = t(reminder.label); + return translated === reminder.label ? reminder.label : translated; + } + return reminder.label; +} + +export function VorsorgeDashboardCard() { + const { t } = useTranslations(); + const { data: reminders, isLoading } = useMeasurementReminders(); + const { satisfy } = useMeasurementReminderMutations(); + const [now] = useState(() => Date.now()); + const [capturing, setCapturing] = useState(null); + const [captureFooterEl, setCaptureFooterEl] = + useState(null); + + // Active reminders only (the manage toggles hide disabled ones from the + // summary), most-urgent first as the API already sorts them. + const upcoming = (reminders ?? []) + .filter((r) => r.enabled) + .slice(0, SUMMARY_LIMIT); + + function onPrimaryAction(reminder: MeasurementReminder) { + if (reminder.measurementType) { + setCapturing(reminder); + } else { + satisfy.mutate(reminder.id); + } + } + + function onCaptureSuccess() { + const reminder = capturing; + setCapturing(null); + if (reminder) satisfy.mutate(reminder.id); + } + + return ( + + + + + + + {isLoading ? ( +
+ {Array.from({ length: 2 }, (_, i) => ( + + ))} +
+ ) : upcoming.length === 0 ? ( +
+

+ {t("measurementReminders.dashboard.empty")} +

+ +
+ ) : ( +
    + {upcoming.map((reminder) => { + const due = relativeDueKey(reminder.nextDueAt, now); + const isLinked = reminder.measurementType != null; + return ( +
  • +
    +

    + {resolveLabel(reminder, t)} +

    + + {t(`measurementReminders.${due.key}`, { days: due.days })} + +
    + +
  • + ); + })} +
+ )} +
+ + { + 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 = ( + + + setManageOpen(true)}> + + {t("measurementReminders.manage.menuItem")} + + + + ); + return (
{variant === "page" ? ( @@ -241,7 +378,10 @@ export function VorsorgeSection({ {t("measurementReminders.sectionDescription")}

- {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. */}
- setForm((f) => ({ - ...f, - intervalDays: Number(e.target.value), - })) + setForm((f) => ({ ...f, cadenceChoice: e.target.value })) } > {INTERVAL_PRESETS.map((days) => ( - ))} + +
@@ -394,6 +536,61 @@ export function VorsorgeSection({
+ {cadenceCustom && ( +
+ + + setForm((f) => ({ + ...f, + customIntervalDays: Number(e.target.value), + })) + } + /> +
+ )} + + {cadenceRrule && ( +
+ + + setForm((f) => ({ ...f, rrule: e.target.value })) + } + /> +

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

+
+ )} + +
+ + + setForm((f) => ({ ...f, anchorDate: e.target.value })) + } + /> +
+
+ {/* 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 ? ( +

+ {t("measurementReminders.manage.empty")} +

+ ) : ( +
    + {reminders?.map((reminder) => ( +
  • + + {resolveReminderLabel(reminder, t)} + + + update.mutate({ id: reminder.id, body: { enabled: next } }) + } + disabled={update.isPending} + aria-label={t("measurementReminders.enabledToggleAria")} + /> +
  • + ))} +
+ )} +
+ {isLoading && ( -
- {Array.from({ length: 3 }, (_, i) => ( -