From 89b6516803d081bfbed68b053cb9e309249c3d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 17 Jun 2026 07:45:36 +0200 Subject: [PATCH 1/3] feat(illness): clarify the condition-journal layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the illness list so each episode reads as a medication-styled card: the header carries the label link, neutral type / status badges, and the overflow kebab; a single bottom-pinned "Log a day" button is the one explicit action per card. Drop the redundant chevron — the card title is already the link. Split the flat list into Active / Resolved groups (active first) and nest flares under their parent condition so the parent relationship the form captures becomes visible. Lift the retrospective summary above the list so a long history no longer buries it. Host the per-day timeline exclusively on the detail surface: the list rows stay clean summaries, and navigating reveals today's logged symptoms, functional impact, fever, and note alongside the correlation card. Neutral palette and retrospective-only copy throughout. --- messages/de.json | 18 ++ messages/en.json | 18 ++ messages/es.json | 18 ++ messages/fr.json | 18 ++ messages/it.json | 18 ++ messages/pl.json | 18 ++ .../illness/illness-day-timeline.tsx | 127 ++++++++ .../illness/illness-episode-detail.tsx | 12 + src/components/illness/illness-view.tsx | 271 +++++++++++++----- 9 files changed, 454 insertions(+), 64 deletions(-) create mode 100644 src/components/illness/illness-day-timeline.tsx diff --git a/messages/de.json b/messages/de.json index 35477aa3c..fdc712ad2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6791,6 +6791,24 @@ "byMonth": "Wann du Episoden erfasst", "none": "In diesem Zeitraum keine Episoden erfasst." }, + "section": { + "active": "Aktiv", + "resolved": "Abgeschlossen" + }, + "flares": { + "countOne": "1 Schub", + "countOther": "{count} Schübe" + }, + "timeline": { + "title": "Tagesverlauf", + "error": "Der heutige Eintrag konnte nicht geladen werden.", + "emptyToday": "Heute noch nichts erfasst.", + "today": "Heute · {date}", + "severity": "Stärke {severity}", + "noSymptoms": "Keine Symptome erfasst.", + "impact": "Befinden: {impact}", + "fever": "Temperatur: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/en.json b/messages/en.json index b651f7a98..3c9a8efb3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6791,6 +6791,24 @@ "byMonth": "When you tend to log episodes", "none": "No episodes logged in this period." }, + "section": { + "active": "Active", + "resolved": "Resolved" + }, + "flares": { + "countOne": "1 flare", + "countOther": "{count} flares" + }, + "timeline": { + "title": "Day timeline", + "error": "Could not load today's entry.", + "emptyToday": "Nothing logged today yet.", + "today": "Today · {date}", + "severity": "Severity {severity}", + "noSymptoms": "No symptoms logged.", + "impact": "How you functioned: {impact}", + "fever": "Temperature: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/es.json b/messages/es.json index 53f0e1d22..240620c60 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6791,6 +6791,24 @@ "byMonth": "Cuándo sueles registrar episodios", "none": "Sin episodios registrados en este período." }, + "section": { + "active": "Activos", + "resolved": "Resueltos" + }, + "flares": { + "countOne": "1 brote", + "countOther": "{count} brotes" + }, + "timeline": { + "title": "Cronología del día", + "error": "No se pudo cargar el registro de hoy.", + "emptyToday": "Aún no hay nada registrado hoy.", + "today": "Hoy · {date}", + "severity": "Intensidad {severity}", + "noSymptoms": "Sin síntomas registrados.", + "impact": "Cómo funcionaste: {impact}", + "fever": "Temperatura: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/fr.json b/messages/fr.json index a148aa1c1..a628cf66e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6791,6 +6791,24 @@ "byMonth": "Quand tu enregistres des épisodes", "none": "Aucun épisode enregistré sur cette période." }, + "section": { + "active": "Actifs", + "resolved": "Résolus" + }, + "flares": { + "countOne": "1 poussée", + "countOther": "{count} poussées" + }, + "timeline": { + "title": "Chronologie du jour", + "error": "Impossible de charger l'entrée du jour.", + "emptyToday": "Rien d'enregistré aujourd'hui pour l'instant.", + "today": "Aujourd'hui · {date}", + "severity": "Intensité {severity}", + "noSymptoms": "Aucun symptôme enregistré.", + "impact": "Votre forme : {impact}", + "fever": "Température : {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/it.json b/messages/it.json index b7d55971b..21baf7519 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6791,6 +6791,24 @@ "byMonth": "Quando tendi a registrare episodi", "none": "Nessun episodio registrato in questo periodo." }, + "section": { + "active": "Attivi", + "resolved": "Risolti" + }, + "flares": { + "countOne": "1 riacutizzazione", + "countOther": "{count} riacutizzazioni" + }, + "timeline": { + "title": "Cronologia del giorno", + "error": "Impossibile caricare il diario di oggi.", + "emptyToday": "Oggi non hai ancora registrato nulla.", + "today": "Oggi · {date}", + "severity": "Intensità {severity}", + "noSymptoms": "Nessun sintomo registrato.", + "impact": "Come ti sei sentito: {impact}", + "fever": "Temperatura: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/messages/pl.json b/messages/pl.json index 20bda86fc..a9302ce93 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6791,6 +6791,24 @@ "byMonth": "Kiedy zwykle zapisujesz epizody", "none": "Brak epizodów zapisanych w tym okresie." }, + "section": { + "active": "Aktywne", + "resolved": "Zakończone" + }, + "flares": { + "countOne": "1 zaostrzenie", + "countOther": "Zaostrzenia: {count}" + }, + "timeline": { + "title": "Oś dnia", + "error": "Nie udało się wczytać dzisiejszego wpisu.", + "emptyToday": "Dziś nic jeszcze nie zapisano.", + "today": "Dziś · {date}", + "severity": "Nasilenie {severity}", + "noSymptoms": "Nie zapisano objawów.", + "impact": "Jak funkcjonowałeś: {impact}", + "fever": "Temperatura: {value} °C" + }, "loadError": "Could not load this episode.", "backToList": "Back to journal", "detail": { diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx new file mode 100644 index 000000000..291ddb2bf --- /dev/null +++ b/src/components/illness/illness-day-timeline.tsx @@ -0,0 +1,127 @@ +"use client"; + +/** + * v1.18.2 — the per-day timeline anchor, hosted exclusively on the episode + * detail surface. It surfaces today's logged day (symptoms with their 0–3 + * severity, functional impact, fever) so the detail page owns the day-log + * content the list rows no longer carry. Retrospective only — a record of + * what was logged, never a forecast. Neutral palette throughout. + * + * The read uses the single-day `useIllnessDayLog` hook (the only day-log read + * the API exposes); when nothing is logged for the day it renders a calm + * prompt to log it. + */ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; + +import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { useIllnessDayLog } from "./use-illness"; + +function symptomLabelKey(key: string): string | null { + return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null; +} + +export function IllnessDayTimeline({ + episodeId, + date, + onLogDay, +}: { + episodeId: string; + date: string; + onLogDay: () => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { data, isLoading, isError } = useIllnessDayLog(episodeId, date); + + return ( + + +

+ {t("illness.timeline.title")} +

+
+ + {isLoading ? ( +
+ + +
+ ) : isError ? ( +

+ {t("illness.timeline.error")} +

+ ) : !data ? ( +
+

+ {t("illness.timeline.emptyToday")} +

+ +
+ ) : ( +
+

+ {t("illness.timeline.today", { + date: fmt.dateShort(`${data.date}T12:00:00`), + })} +

+ + {data.symptoms.length > 0 ? ( +
+ {data.symptoms.map((s) => { + const labelKey = symptomLabelKey(s.key); + if (!labelKey) return null; + return ( + + {t(labelKey)} + {typeof s.severity === "number" + ? ` · ${t("illness.timeline.severity", { + severity: s.severity, + })}` + : ""} + + ); + })} +
+ ) : ( +

+ {t("illness.timeline.noSymptoms")} +

+ )} + + {data.functionalImpact !== null ? ( +

+ {t("illness.timeline.impact", { + impact: t(`illness.impact.${data.functionalImpact}`), + })} +

+ ) : null} + + {data.feverC !== null ? ( +

+ {t("illness.timeline.fever", { + value: fmt.number(data.feverC, 1), + })} +

+ ) : null} + + {data.note ? ( +

+ {data.note} +

+ ) : null} +
+ )} +
+
+ ); +} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 4ad5f5e7f..e4262edb6 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -17,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { IllnessCorrelationCard } from "./illness-correlation-card"; +import { IllnessDayTimeline } from "./illness-day-timeline"; import { LogDaySheet } from "./log-day-sheet"; import { NewEpisodeSheet } from "./new-episode-sheet"; import { EpisodeMenu } from "./episode-menu"; @@ -114,6 +115,17 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { ) : null} + {/* The per-day timeline lives only on the detail surface — the list + rows stay clean summaries, so navigating here reveals genuinely new + content (today's logged symptoms / impact / fever). */} + {episode ? ( + setLogOpen(true)} + /> + ) : null} + {isLoading ? ( ) : ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 890bdda9e..88d533285 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -1,25 +1,36 @@ "use client"; /** - * v1.18.1 — the illness / condition-journal surface. + * v1.18.2 — the illness / condition-journal surface. * - * A calm, retrospective journal: the episode history (newest first) with a - * neutral status Badge (active / recovered — never an alarming colour, the - * med-card rule generalised), a shared overflow kebab (Edit / Mark recovered / - * AlertDialog-gated Delete), a "log a day" entry, and the cross-episode - * retrospective summary. Each row navigates to the episode-detail surface - * (`/illness/[id]`) for the correlation card. Retrospective-only copy — a + * A calm, retrospective journal. The episode history reads as a grid of + * medication-styled cards: a header (label link + neutral type / status + * badges + an overflow kebab) and a single bottom-pinned "Log a day" + * primary action — one clear action per card, the most-common one promoted. + * The list is split into "Active" and "Resolved" groups (active first), and + * flares nest under their parent condition so the parent relationship the + * form captures is visible. The cross-episode retrospective summary sits + * directly under the header so a long list never buries it. Each card + * navigates to the episode-detail surface (`/illness/[id]`) where the + * per-day timeline and correlation card live. Retrospective-only copy — a * journal, not a medical device, and it does not diagnose. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; -import { ChevronRight, Plus, Stethoscope } from "lucide-react"; +import { Plus, Stethoscope } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { LogDaySheet } from "./log-day-sheet"; @@ -38,29 +49,55 @@ function todayLocal(): string { return `${y}-${m}-${d}`; } -function EpisodeRow({ - episode, - onLogDay, - onEdit, - onResolve, - resolving, -}: { +interface EpisodeCardProps { episode: IllnessEpisodeDTO; + /** Flares that hang off this episode (rendered nested), already filtered. */ + flares?: IllnessEpisodeDTO[]; onLogDay: (id: string) => void; onEdit: (episode: IllnessEpisodeDTO) => void; onResolve: (id: string) => void; resolving: boolean; -}) { +} + +function EpisodeCard({ + episode, + flares, + onLogDay, + onEdit, + onResolve, + resolving, +}: EpisodeCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); const active = episode.resolvedAt === null; const isChronic = episode.lifecycle === "CHRONIC_ONGOING"; + const flareCount = flares?.length ?? 0; return ( - - -
- {episode.label} + + + + + {episode.label} + + + + onEdit(episode)} + onResolve={ + active && !isChronic ? () => onResolve(episode.id) : undefined + } + resolving={resolving} + /> + + + + +
{t(`illness.type.${episode.type}`)} {active @@ -70,41 +107,100 @@ function EpisodeRow({ : t("illness.status.recovered")}
-

- {t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })} + +

+ {t("illness.onsetOn", { + date: fmt.dateShort(new Date(episode.onsetAt)), + })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { date: fmt.dateShort(new Date(episode.resolvedAt)), })}` : ""}

- -
- - onEdit(episode)} - onResolve={ - active && !isChronic ? () => onResolve(episode.id) : undefined - } - resolving={resolving} - /> - -
+ + {flareCount > 0 ? ( +
+

+ {flareCount === 1 + ? t("illness.flares.countOne") + : t("illness.flares.countOther", { count: flareCount })} +

+
    + {flares!.map((flare) => ( +
  • + + {flare.label} + + {fmt.dateShort(new Date(flare.onsetAt))} + + +
  • + ))} +
+
+ ) : null} + + {/* Bottom-pinned single primary action — the daily-use "log a day" + flow, promoted from a busy inline cluster. The card navigates to + the detail surface on its title link; this is the one explicit + button. */} +
+ +
+
); } +function EpisodeGroup({ + title, + parents, + childrenByParent, + onLogDay, + onEdit, + onResolve, + resolving, +}: { + title: string; + parents: IllnessEpisodeDTO[]; + childrenByParent: Map; + onLogDay: (id: string) => void; + onEdit: (episode: IllnessEpisodeDTO) => void; + onResolve: (id: string) => void; + resolving: boolean; +}) { + if (parents.length === 0) return null; + return ( +
+

+ {title} +

+
+ {parents.map((episode) => ( + + ))} +
+
+ ); +} + export function IllnessView() { const { t } = useTranslations(); const { data: episodes, isLoading } = useIllnessEpisodes(true); @@ -117,6 +213,45 @@ export function IllnessView() { const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null; + /** + * Group the flat list into active / resolved, nesting flares under their + * parent condition. A flare nests only when its parent lives in the same + * status group (so an active flare under a resolved parent still surfaces as + * its own active card rather than vanishing); otherwise it renders as a + * standalone parent card in its own group. + */ + const grouped = useMemo(() => { + const list = episodes ?? []; + const byId = new Map(list.map((e) => [e.id, e])); + const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null; + + const activeParents: IllnessEpisodeDTO[] = []; + const resolvedParents: IllnessEpisodeDTO[] = []; + const childrenByParent = new Map(); + + for (const e of list) { + const parent = e.parentConditionId + ? byId.get(e.parentConditionId) + : undefined; + const nestsUnderParent = + parent !== undefined && isActive(parent) === isActive(e); + + if (nestsUnderParent) { + const bucket = childrenByParent.get(parent.id) ?? []; + bucket.push(e); + childrenByParent.set(parent.id, bucket); + } else if (isActive(e)) { + activeParents.push(e); + } else { + resolvedParents.push(e); + } + } + + return { activeParents, resolvedParents, childrenByParent }; + }, [episodes]); + + const hasEpisodes = (episodes?.length ?? 0) > 0; + return (
@@ -140,26 +275,34 @@ export function IllnessView() {

{t("illness.disclaimer")}

{isLoading ? ( -
- - - +
+ +
- ) : episodes && episodes.length > 0 ? ( + ) : hasEpisodes ? ( <> -
- {episodes.map((episode) => ( - setLogEpisodeId(id)} - onEdit={(e) => setEditEpisode(e)} - onResolve={(id) => resolve.mutate(id)} - /> - ))} -
+ {/* Retrospective summary sits above the list so a long history + never buries it. */} + + setLogEpisodeId(id)} + onEdit={(e) => setEditEpisode(e)} + onResolve={(id) => resolve.mutate(id)} + /> + setLogEpisodeId(id)} + onEdit={(e) => setEditEpisode(e)} + onResolve={(id) => resolve.mutate(id)} + /> ) : ( Date: Wed, 17 Jun 2026 07:52:48 +0200 Subject: [PATCH 2/3] feat(vorsorge): rebuild preventive care to mirror the medication module Each reminder is now its own med-styled card reusing the medication card primitives (MedicationCardHeader / MedicationNextLastSlot) on a neutral surface with a single bottom-pinned action and a kebab carrying edit + delete. The mark-done action branches on the linked measurement type: - a free-text / self-planned exam keeps the silent satisfy ("Done"); - a measurement-linked reminder opens the real measurement-entry form ("Log value") pre-filled with its type, and on a successful write satisfies the reminder in the same action (forward-only guard keeps the cron a no-op). The create/edit sheet gains a first-due date (anchorDate), a free custom interval, and an RFC-5545 RRULE option, all already accepted by the create/update schema. A wrench/customize affordance beside the page Add button re-homes the per-reminder enable toggles moved off the card. Vorsorge also joins the dashboard grid layout like medications: a new chart-row summary card listing the next few due reminders with inline done/capture buttons, default-off so existing dashboards are unchanged. No migration and no API schema change; OpenAPI stays in sync. --- messages/de.json | 28 +- messages/en.json | 28 +- messages/es.json | 28 +- messages/fr.json | 28 +- messages/it.json | 28 +- messages/pl.json | 28 +- src/app/page.tsx | 17 + .../__tests__/vorsorge-section.test.tsx | 53 ++ .../vorsorge-dashboard-card.tsx | 179 +++++ .../vorsorge-section.tsx | 622 +++++++++++++----- .../settings/dashboard-layout-section.tsx | 2 + src/lib/__tests__/dashboard-layout.test.ts | 33 +- src/lib/dashboard-layout.ts | 9 + src/lib/dashboard/__tests__/snapshot.test.ts | 8 +- 14 files changed, 908 insertions(+), 183 deletions(-) create mode 100644 src/components/measurement-reminders/vorsorge-dashboard-card.tsx diff --git a/messages/de.json b/messages/de.json index 35477aa3c..6c73ca454 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 b651f7a98..5a96de8b3 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 53f0e1d22..ab4c81cae 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 a148aa1c1..2c3ab8349 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 b7d55971b..de7692cbc 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 20bda86fc..f8fc4d96b 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) => ( -