feat(vorsorge): per-item medication-style cards, value-capture on complete, custom intervals, wrench + dashboard widget

This commit is contained in:
Marc-André Bombeck committed 2026-06-17 07:53:48 +02:00
commit 15df54b345
14 files changed
+908 -183

No files matched your search

+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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ń",
+17
View File
@@ -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<string>([
"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: <VorsorgeDashboardCard key="vorsorge" />,
});
}
if (showAchievementsCard) {
// v1.4.15 phase-B4 — slotted at the user's chosen position via
// the layout `order`. Default order from
@@ -73,6 +73,59 @@ describe("<VorsorgeSection> 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(<VorsorgeSection />);
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(<VorsorgeSection />);
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",
@@ -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<MeasurementReminder | null>(null);
const [captureFooterEl, setCaptureFooterEl] =
useState<HTMLDivElement | null>(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 (
<Card data-slot="vorsorge-dashboard-card" className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<CalendarClock className="h-4 w-4" aria-hidden="true" />
{t("measurementReminders.sectionTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : upcoming.length === 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
{t("measurementReminders.dashboard.empty")}
</p>
<Button asChild variant="outline" size="sm">
<Link href="/vorsorge">
{t("measurementReminders.dashboard.openLink")}
</Link>
</Button>
</div>
) : (
<ul className="space-y-2">
{upcoming.map((reminder) => {
const due = relativeDueKey(reminder.nextDueAt, now);
const isLinked = reminder.measurementType != null;
return (
<li
key={reminder.id}
className="flex items-center justify-between gap-3 rounded-md border p-2.5"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{resolveLabel(reminder, t)}
</p>
<Badge variant="secondary" className="text-xs">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
</div>
<Button
type="button"
size="sm"
className="shrink-0"
onClick={() => onPrimaryAction(reminder)}
disabled={satisfy.isPending}
>
{isLinked ? (
<>
<Plus className="h-4 w-4" />
{t("measurementReminders.captureValue")}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
{t("measurementReminders.markDone")}
</>
)}
</Button>
</li>
);
})}
</ul>
)}
</CardContent>
<ResponsiveSheet
open={capturing !== null}
onOpenChange={(open) => {
if (!open) setCapturing(null);
}}
title={t("measurementReminders.capture.title")}
description={t("measurementReminders.capture.description")}
footer={<div ref={setCaptureFooterEl} className="flex w-full" />}
>
{capturing && (
<MeasurementForm
defaultType={capturing.measurementType ?? undefined}
onSuccess={onCaptureSuccess}
onCancel={() => setCapturing(null)}
footerSlot={captureFooterEl}
/>
)}
</ResponsiveSheet>
</Card>
);
}
@@ -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 <date>" 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 `<input type="date">` 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);
}
/** `<input type="date">` 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
* (`<h1>` + 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.
* (`<h1>` + 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<FormState>(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<MeasurementReminder | null>(null);
const [captureFooterEl, setCaptureFooterEl] =
useState<HTMLDivElement | null>(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 = (
<Button
type="button"
@@ -226,6 +339,30 @@ export function VorsorgeSection({
</Button>
);
// v1.18.2 — wrench/customize affordance, mirroring the labs page: a
// ghost icon button beside Add opening the manage sheet.
const wrenchButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => setManageOpen(true)}>
<Settings2 className="mr-2 h-4 w-4" />
{t("measurementReminders.manage.menuItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
{variant === "page" ? (
@@ -241,7 +378,10 @@ export function VorsorgeSection({
{t("measurementReminders.sectionDescription")}
</p>
</div>
{addButton}
<div className="flex shrink-0 items-center gap-2">
{addButton}
{wrenchButton}
</div>
</div>
) : (
<SettingsCardHeader
@@ -281,10 +421,9 @@ export function VorsorgeSection({
>
{/* 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. */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
@@ -359,19 +498,22 @@ export function VorsorgeSection({
</Label>
<NativeSelect
id="vorsorge-interval"
value={String(form.intervalDays)}
value={form.cadenceChoice}
onChange={(e) =>
setForm((f) => ({
...f,
intervalDays: Number(e.target.value),
}))
setForm((f) => ({ ...f, cadenceChoice: e.target.value }))
}
>
{INTERVAL_PRESETS.map((days) => (
<option key={days} value={days}>
<option key={days} value={String(days)}>
{t("measurementReminders.cadence.everyNDays", { days })}
</option>
))}
<option value={CADENCE_CUSTOM}>
{t("measurementReminders.cadence.customInterval")}
</option>
<option value={CADENCE_RRULE}>
{t("measurementReminders.cadence.rruleOption")}
</option>
</NativeSelect>
</div>
<div className="space-y-2">
@@ -394,6 +536,61 @@ export function VorsorgeSection({
</div>
</div>
{cadenceCustom && (
<div className="space-y-2">
<Label htmlFor="vorsorge-custom-interval">
{t("measurementReminders.form.customInterval")}
</Label>
<Input
id="vorsorge-custom-interval"
type="number"
min={1}
max={3650}
value={form.customIntervalDays}
onChange={(e) =>
setForm((f) => ({
...f,
customIntervalDays: Number(e.target.value),
}))
}
/>
</div>
)}
{cadenceRrule && (
<div className="space-y-2">
<Label htmlFor="vorsorge-rrule">
{t("measurementReminders.form.rrule")}
</Label>
<Input
id="vorsorge-rrule"
value={form.rrule}
maxLength={512}
placeholder="FREQ=YEARLY"
onChange={(e) =>
setForm((f) => ({ ...f, rrule: e.target.value }))
}
/>
<p className="text-muted-foreground text-xs">
{t("measurementReminders.form.rruleHint")}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="vorsorge-anchor">
{t("measurementReminders.form.firstDue")}
</Label>
<Input
id="vorsorge-anchor"
type="date"
value={form.anchorDate}
onChange={(e) =>
setForm((f) => ({ ...f, anchorDate: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-location">
{t("measurementReminders.form.location")}
@@ -411,16 +608,76 @@ export function VorsorgeSection({
</div>
</ResponsiveSheet>
{/* 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. */}
<ResponsiveSheet
open={capturing !== null}
onOpenChange={(open) => {
if (!open) setCapturing(null);
}}
title={t("measurementReminders.capture.title")}
description={t("measurementReminders.capture.description")}
footer={<div ref={setCaptureFooterEl} className="flex w-full" />}
>
{capturing && (
<MeasurementForm
defaultType={capturing.measurementType ?? undefined}
onSuccess={onCaptureSuccess}
onCancel={() => setCapturing(null)}
footerSlot={captureFooterEl}
/>
)}
</ResponsiveSheet>
{/* 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). */}
<ResponsiveSheet
open={manageOpen}
onOpenChange={setManageOpen}
title={t("measurementReminders.manage.title")}
description={t("measurementReminders.manage.description")}
>
{(reminders?.length ?? 0) === 0 ? (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.manage.empty")}
</p>
) : (
<ul className="space-y-2">
{reminders?.map((reminder) => (
<li
key={reminder.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<span className="min-w-0 truncate text-sm">
{resolveReminderLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
update.mutate({ id: reminder.id, body: { enabled: next } })
}
disabled={update.isPending}
aria-label={t("measurementReminders.enabledToggleAria")}
/>
</li>
))}
</ul>
)}
</ResponsiveSheet>
{isLoading && (
<div className="space-y-3" data-slot="vorsorge-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-8 w-24 shrink-0" />
<div
className="grid gap-4 sm:grid-cols-2"
data-slot="vorsorge-loading"
>
{Array.from({ length: 4 }, (_, i) => (
<Card key={i} aria-hidden="true" className="h-full gap-3">
<CardContent className="space-y-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-11 w-full" />
</CardContent>
</Card>
))}
@@ -440,22 +697,24 @@ export function VorsorgeSection({
/>
)}
<ul className="space-y-3">
{reminders?.map((reminder) => (
<VorsorgeCard
key={reminder.id}
reminder={reminder}
now={now}
onSatisfy={() => satisfy.mutate(reminder.id)}
onRemove={() => remove.mutate(reminder.id)}
onEdit={() => openEdit(reminder)}
onToggleEnabled={(next) =>
update.mutate({ id: reminder.id, body: { enabled: next } })
}
busy={satisfy.isPending || remove.isPending || update.isPending}
/>
))}
</ul>
{!isLoading && (reminders?.length ?? 0) > 0 && (
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
{reminders?.map((reminder) => (
<li key={reminder.id} className="contents">
<VorsorgeCard
reminder={reminder}
now={now}
onPrimaryAction={() => onPrimaryAction(reminder)}
onRemove={() => remove.mutate(reminder.id)}
onEdit={() => openEdit(reminder)}
busy={
satisfy.isPending || remove.isPending || update.isPending
}
/>
</li>
))}
</ul>
)}
</section>
);
}
@@ -463,22 +722,21 @@ export function VorsorgeSection({
function VorsorgeCard({
reminder,
now,
onSatisfy,
onPrimaryAction,
onRemove,
onEdit,
onToggleEnabled,
busy,
}: {
reminder: MeasurementReminder;
now: number;
onSatisfy: () => void;
onPrimaryAction: () => void;
onRemove: () => void;
onEdit: () => void;
onToggleEnabled: (next: boolean) => void;
busy: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [confirmDelete, setConfirmDelete] = useState(false);
const due = relativeDueKey(reminder.nextDueAt, now);
const cadence =
reminder.intervalDays != null
@@ -489,91 +747,151 @@ function VorsorgeCard({
? t("measurementReminders.cadence.custom")
: "";
const isCoach = reminder.origin === "COACH";
const isLinked = reminder.measurementType != null;
// Category-style header badge: the measurement label, or "self-planned"
// for a free-text exam — mirroring the medication card's class badge.
const categoryLabel = isLinked
? t(`measurementReminders.types.${reminder.measurementType}`)
: t("measurementReminders.selfPlanned");
const stateBadges =
isCoach || !reminder.enabled ? (
<>
{isCoach && (
<Badge variant="outline">
{t("measurementReminders.originCoach")}
</Badge>
)}
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</>
) : null;
// Single med-style kebab: Edit + Delete. The Delete item is the shared
// confirm-on-delete control rendered as a full-width menu row.
const headerActions = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={onEdit} disabled={busy}>
<Pencil className="mr-2 h-4 w-4" />
{t("measurementReminders.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={busy}
onSelect={() => setConfirmDelete(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("measurementReminders.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
// Next/last block in the med-card grammar. The "next" value carries the
// discreet neutral due badge + cadence; the "last" value is the last
// satisfied date.
const nextValue = (
<span className="inline-flex flex-wrap items-center justify-end gap-x-2 gap-y-1">
<Badge variant="secondary">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
{cadence && <span className="text-muted-foreground">{cadence}</span>}
</span>
);
const lastValue = reminder.lastSatisfiedAt
? fmt.date(new Date(reminder.lastSatisfiedAt))
: null;
return (
<li>
{/* NEUTRAL card — no status-driven colour. The due state reads only
through the discreet badge below. */}
<Card>
<CardHeader>
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
<span className="truncate font-medium">
{resolveReminderLabel(reminder, t)}
</span>
{isCoach && (
// Neutral provenance badge — no status colour per the rule.
<Badge variant="outline">
{t("measurementReminders.originCoach")}
</Badge>
)}
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</CardTitle>
<CardAction className="flex items-center gap-1">
<Switch
checked={reminder.enabled}
onCheckedChange={onToggleEnabled}
disabled={busy}
aria-label={t("measurementReminders.enabledToggleAria")}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={onSatisfy}
disabled={busy}
aria-label={t("measurementReminders.markDone")}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
{t("measurementReminders.markDone")}
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={onEdit}
disabled={busy}
className="size-9"
aria-label={t("measurementReminders.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={onRemove}
title={t("measurementReminders.deleteConfirmTitle")}
description={t("measurementReminders.deleteConfirmDescription")}
confirmLabel={t("measurementReminders.delete")}
className="size-9"
iconClassName="h-4 w-4"
/>
</CardAction>
</CardHeader>
<CardContent className="space-y-1">
<>
{/* NEUTRAL card — no status-driven colour. Due state reads only through
the discreet badge below. Shares the medication card shell + tokens. */}
<Card className="h-full gap-3 md:gap-3">
<MedicationCardHeader
name={resolveReminderLabel(reminder, t)}
dose=""
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
<MedicationNextLastSlot next={nextValue} last={lastValue} />
{reminder.endsOn && (
<p className="text-muted-foreground text-sm">
<Badge variant="secondary" className="mr-2">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
{cadence}
{t("measurementReminders.until", {
date: fmt.date(new Date(reminder.endsOn)),
})}
</p>
{reminder.endsOn && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.until", {
date: fmt.date(new Date(reminder.endsOn)),
})}
</p>
)}
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</CardContent>
</Card>
</li>
)}
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
{/* 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. */}
<div className="mt-auto pt-0">
<Button
type="button"
className="min-h-11 w-full"
onClick={onPrimaryAction}
disabled={busy}
>
{isLinked ? (
<>
<Plus className="h-4 w-4" />
{t("measurementReminders.captureValue")}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
{t("measurementReminders.markDone")}
</>
)}
</Button>
</div>
</CardContent>
</Card>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurementReminders.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurementReminders.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onRemove}>
{t("measurementReminders.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -146,6 +146,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
wristTemperature: "measurements.typeWristTemperature",
falls: "measurements.typeFallCount",
walkingSteadiness: "measurements.typeWalkingSteadiness",
// v1.18.2 — Vorsorge preventive-care summary card.
vorsorge: "measurementReminders.sectionTitle",
};
export function DashboardLayoutSection({ id }: { id: string }) {
+24 -9
View File
@@ -443,13 +443,28 @@ describe("resolveDashboardLayout() — heroVisible", () => {
* iOS-only ids extend it without touching the writable PUT enum.
*/
describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
it("carries exactly 35 distinct ids (24 server-known + 11 iOS-only)", () => {
it("carries exactly 36 distinct ids (25 server-known + 11 iOS-only)", () => {
// v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so
// the server-known set grew 16 → 24 and the catalogue 27 → 35.
expect(DASHBOARD_WIDGET_IDS).toHaveLength(24);
// v1.18.2 — the Vorsorge summary widget added one server-known id
// (24 → 25), so the catalogue grew 35 → 36.
expect(DASHBOARD_WIDGET_IDS).toHaveLength(25);
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(11);
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(35);
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(35);
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(36);
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(36);
});
it("registers the Vorsorge widget default-off on both surfaces", () => {
// v1.18.2 — Vorsorge becomes a first-class dashboard widget like
// medications: chart-row only, default-invisible so existing
// dashboards are unchanged until the user opts in.
expect(DASHBOARD_WIDGET_IDS).toContain("vorsorge");
const entry = DEFAULT_DASHBOARD_LAYOUT.widgets.find(
(w) => w.id === "vorsorge",
);
expect(entry).toBeDefined();
expect(entry?.visible).toBe(false);
expect(entry?.tileVisible).toBe(false);
});
it("is a superset of the server-known ids in declaration order", () => {
@@ -590,17 +605,17 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
expect(ids).not.toContain("totally-made-up");
});
it("keeps the default layout at the 24 web tiles (no iOS-only seeded)", () => {
it("keeps the default layout at the 25 web tiles (no iOS-only seeded)", () => {
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
expect(ids).toHaveLength(24);
expect(ids).toHaveLength(25);
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(ids).not.toContain(iosId);
}
});
it("does NOT auto-append iOS-only ids when a web-only layout is read", () => {
// A web account that saved only `weight` must auto-upgrade to the 24
// web defaults — never to the 35 catalogue. iOS-only ids appear only
// A web account that saved only `weight` must auto-upgrade to the 25
// web defaults — never to the 36 catalogue. iOS-only ids appear only
// once a native client has explicitly sent them.
const partial = {
version: 1,
@@ -608,7 +623,7 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
};
const resolved = resolveDashboardLayout(partial);
const ids = resolved.widgets.map((w) => w.id);
expect(ids).toHaveLength(24);
expect(ids).toHaveLength(25);
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(ids).not.toContain(iosId);
}
+9
View File
@@ -54,6 +54,12 @@ export const DASHBOARD_WIDGET_IDS = [
"wristTemperature",
"falls",
"walkingSteadiness",
// v1.18.2 — Vorsorge (preventive-care reminders) becomes a first-class
// dashboard widget, exactly like `medications`: a chart-row summary card
// (no strip tile). Default-off so existing dashboards are unchanged; the
// user opts in via Settings → Dashboard. Always-on data surface (no
// toggleable module gate), so it behaves as a core opt-in tile.
"vorsorge",
] as const;
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
@@ -402,6 +408,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
{ id: "wristTemperature", visible: false, tileVisible: false, order: 21 },
{ id: "falls", visible: false, tileVisible: false, order: 22 },
{ id: "walkingSteadiness", visible: false, tileVisible: false, order: 23 },
// v1.18.2 — Vorsorge summary card. Chart-row only (no strip tile), so
// `tileVisible` is forced false; default-invisible until opt-in.
{ id: "vorsorge", visible: false, tileVisible: false, order: 24 },
],
};
+4 -4
View File
@@ -731,11 +731,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () =>
isFullyCovered.mockReturnValue(false);
});
it("emits all 35 catalogue ids with visibility + order", async () => {
it("emits all 36 catalogue ids with visibility + order", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.layoutCatalogue).toHaveLength(35);
expect(snap.layoutCatalogue).toHaveLength(36);
const ids = new Set(snap.layoutCatalogue.map((w) => w.id));
expect(ids.size).toBe(35);
expect(ids.size).toBe(36);
// iOS-only ids appended default-invisible.
const hrv = snap.layoutCatalogue.find((w) => w.id === "hrv");
expect(hrv).toBeDefined();
@@ -806,7 +806,7 @@ describe("buildDashboardSnapshot — additive proof", () => {
expect(snap.layout).toHaveProperty("version");
expect(Array.isArray(snap.layout.widgets)).toBe(true);
// Resolved layout still carries only server-known widget ids.
expect(snap.layout.widgets.length).toBeLessThanOrEqual(24);
expect(snap.layout.widgets.length).toBeLessThanOrEqual(25);
// tiles shape unchanged.
expect(snap.tiles).toHaveProperty("summaries");