mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(reminders): preventive-care surface — wann, was, wo
Add the Vorsorge page and section: an upcoming-reminders list sorted by server-computed next-due, with a create form (measurement-linked or free-text, cadence, notify hour, location) and a per-card Erledigt and delete action. Cards stay neutral-coloured regardless of due state — the status reads through a discreet badge only, never an alarming tint. Copy ships in all six locales.
This commit is contained in:
1 parent
8058d81590
commit
c6045482c3
11 files changed
+826
No files matched your search
@@ -6208,5 +6208,56 @@
|
||||
"deleteN": "Löschen ({count})",
|
||||
"clearSelection": "Auswahl aufheben",
|
||||
"selectionBarLabel": "Auswahlaktionen"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Vorsorge",
|
||||
"sectionDescription": "Erinnerungen, was wann und wo zu messen oder zu prüfen ist.",
|
||||
"addButton": "Erinnerung hinzufügen",
|
||||
"markDone": "Erledigt",
|
||||
"delete": "Löschen",
|
||||
"disabledBadge": "Aus",
|
||||
"nextDue": {
|
||||
"none": "Nicht geplant",
|
||||
"today": "Heute fällig",
|
||||
"tomorrow": "Morgen fällig",
|
||||
"inDays": "In {days} Tagen"
|
||||
},
|
||||
"overdueByDays": "Überfällig seit {days} Tagen",
|
||||
"cadence": {
|
||||
"everyNDays": "Alle {days} Tage",
|
||||
"custom": "Eigener Zeitplan"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "Bei: {location}"
|
||||
},
|
||||
"types": {
|
||||
"WEIGHT": "Gewicht",
|
||||
"BLOOD_PRESSURE_SYS": "Blutdruck",
|
||||
"PULSE": "Puls",
|
||||
"BLOOD_GLUCOSE": "Blutzucker",
|
||||
"OXYGEN_SATURATION": "Sauerstoffsättigung",
|
||||
"BODY_FAT": "Körperfett",
|
||||
"BODY_TEMPERATURE": "Körpertemperatur"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Erinnerungen",
|
||||
"description": "Füge eine Erinnerung hinzu, um z. B. regelmäßig den Blutdruck zu messen oder ein jährliches Blutbild zu planen."
|
||||
},
|
||||
"form": {
|
||||
"label": "Bezeichnung",
|
||||
"labelPlaceholder": "z. B. Blutdruck messen",
|
||||
"kind": "Art",
|
||||
"kindType": "Mit einer Messung verknüpft",
|
||||
"kindFreeText": "Freitext-Prüfung",
|
||||
"measurementType": "Messwert",
|
||||
"cadence": "Wiederholung",
|
||||
"notifyHour": "Erinnern um",
|
||||
"location": "Wo (optional)",
|
||||
"locationPlaceholder": "z. B. Hausarzt, Labor",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"pushTitle": "Vorsorge-Erinnerung",
|
||||
"pushBody": "Fällig: {label}",
|
||||
"pushLocation": "(bei {location})"
|
||||
}
|
||||
}
|
||||
@@ -6208,5 +6208,56 @@
|
||||
"deleteN": "Delete ({count})",
|
||||
"clearSelection": "Clear selection",
|
||||
"selectionBarLabel": "Selection actions"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Preventive care",
|
||||
"sectionDescription": "Reminders for what to measure or check, when, and where.",
|
||||
"addButton": "Add reminder",
|
||||
"markDone": "Done",
|
||||
"delete": "Delete",
|
||||
"disabledBadge": "Off",
|
||||
"nextDue": {
|
||||
"none": "Not scheduled",
|
||||
"today": "Due today",
|
||||
"tomorrow": "Due tomorrow",
|
||||
"inDays": "In {days} days"
|
||||
},
|
||||
"overdueByDays": "Overdue by {days} days",
|
||||
"cadence": {
|
||||
"everyNDays": "Every {days} days",
|
||||
"custom": "Custom schedule"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "At: {location}"
|
||||
},
|
||||
"types": {
|
||||
"WEIGHT": "Weight",
|
||||
"BLOOD_PRESSURE_SYS": "Blood pressure",
|
||||
"PULSE": "Pulse",
|
||||
"BLOOD_GLUCOSE": "Blood glucose",
|
||||
"OXYGEN_SATURATION": "Oxygen saturation",
|
||||
"BODY_FAT": "Body fat",
|
||||
"BODY_TEMPERATURE": "Body temperature"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No reminders yet",
|
||||
"description": "Add a reminder to measure your blood pressure on a cadence, or to schedule an annual blood panel."
|
||||
},
|
||||
"form": {
|
||||
"label": "Label",
|
||||
"labelPlaceholder": "e.g. Measure blood pressure",
|
||||
"kind": "Type",
|
||||
"kindType": "Linked to a measurement",
|
||||
"kindFreeText": "Free-text check",
|
||||
"measurementType": "Measurement",
|
||||
"cadence": "Repeat",
|
||||
"notifyHour": "Remind at",
|
||||
"location": "Where (optional)",
|
||||
"locationPlaceholder": "e.g. GP, Lab",
|
||||
"save": "Save"
|
||||
},
|
||||
"pushTitle": "Preventive-care reminder",
|
||||
"pushBody": "Time for: {label}",
|
||||
"pushLocation": "(at {location})"
|
||||
}
|
||||
}
|
||||
@@ -6208,5 +6208,56 @@
|
||||
"deleteN": "Eliminar ({count})",
|
||||
"clearSelection": "Borrar selección",
|
||||
"selectionBarLabel": "Acciones de selección"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Prevención",
|
||||
"sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.",
|
||||
"addButton": "Añadir recordatorio",
|
||||
"markDone": "Hecho",
|
||||
"delete": "Eliminar",
|
||||
"disabledBadge": "Apagado",
|
||||
"nextDue": {
|
||||
"none": "Sin programar",
|
||||
"today": "Vence hoy",
|
||||
"tomorrow": "Vence mañana",
|
||||
"inDays": "En {days} días"
|
||||
},
|
||||
"overdueByDays": "Vencido hace {days} días",
|
||||
"cadence": {
|
||||
"everyNDays": "Cada {days} días",
|
||||
"custom": "Programación personalizada"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "En: {location}"
|
||||
},
|
||||
"types": {
|
||||
"WEIGHT": "Peso",
|
||||
"BLOOD_PRESSURE_SYS": "Presión arterial",
|
||||
"PULSE": "Pulso",
|
||||
"BLOOD_GLUCOSE": "Glucosa en sangre",
|
||||
"OXYGEN_SATURATION": "Saturación de oxígeno",
|
||||
"BODY_FAT": "Grasa corporal",
|
||||
"BODY_TEMPERATURE": "Temperatura corporal"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aún no hay recordatorios",
|
||||
"description": "Añade un recordatorio para medir la presión arterial con regularidad o programar un análisis de sangre anual."
|
||||
},
|
||||
"form": {
|
||||
"label": "Etiqueta",
|
||||
"labelPlaceholder": "p. ej. Medir la presión arterial",
|
||||
"kind": "Tipo",
|
||||
"kindType": "Vinculado a una medición",
|
||||
"kindFreeText": "Comprobación de texto libre",
|
||||
"measurementType": "Medición",
|
||||
"cadence": "Repetir",
|
||||
"notifyHour": "Recordar a las",
|
||||
"location": "Dónde (opcional)",
|
||||
"locationPlaceholder": "p. ej. Médico, Laboratorio",
|
||||
"save": "Guardar"
|
||||
},
|
||||
"pushTitle": "Recordatorio de prevención",
|
||||
"pushBody": "Toca: {label}",
|
||||
"pushLocation": "(en {location})"
|
||||
}
|
||||
}
|
||||
@@ -6208,5 +6208,56 @@
|
||||
"deleteN": "Supprimer ({count})",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"selectionBarLabel": "Actions de sélection"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Prévention",
|
||||
"sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.",
|
||||
"addButton": "Ajouter un rappel",
|
||||
"markDone": "Terminé",
|
||||
"delete": "Supprimer",
|
||||
"disabledBadge": "Désactivé",
|
||||
"nextDue": {
|
||||
"none": "Non planifié",
|
||||
"today": "À faire aujourd'hui",
|
||||
"tomorrow": "À faire demain",
|
||||
"inDays": "Dans {days} jours"
|
||||
},
|
||||
"overdueByDays": "En retard de {days} jours",
|
||||
"cadence": {
|
||||
"everyNDays": "Tous les {days} jours",
|
||||
"custom": "Planning personnalisé"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "Chez : {location}"
|
||||
},
|
||||
"types": {
|
||||
"WEIGHT": "Poids",
|
||||
"BLOOD_PRESSURE_SYS": "Tension artérielle",
|
||||
"PULSE": "Pouls",
|
||||
"BLOOD_GLUCOSE": "Glycémie",
|
||||
"OXYGEN_SATURATION": "Saturation en oxygène",
|
||||
"BODY_FAT": "Masse grasse",
|
||||
"BODY_TEMPERATURE": "Température corporelle"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucun rappel pour l'instant",
|
||||
"description": "Ajoutez un rappel pour mesurer régulièrement votre tension ou planifier un bilan sanguin annuel."
|
||||
},
|
||||
"form": {
|
||||
"label": "Libellé",
|
||||
"labelPlaceholder": "ex. Mesurer la tension",
|
||||
"kind": "Type",
|
||||
"kindType": "Lié à une mesure",
|
||||
"kindFreeText": "Vérification libre",
|
||||
"measurementType": "Mesure",
|
||||
"cadence": "Répétition",
|
||||
"notifyHour": "Rappeler à",
|
||||
"location": "Où (facultatif)",
|
||||
"locationPlaceholder": "ex. Médecin, Laboratoire",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"pushTitle": "Rappel de prévention",
|
||||
"pushBody": "C'est l'heure : {label}",
|
||||
"pushLocation": "(chez {location})"
|
||||
}
|
||||
}
|
||||
@@ -6208,5 +6208,56 @@
|
||||
"deleteN": "Elimina ({count})",
|
||||
"clearSelection": "Cancella selezione",
|
||||
"selectionBarLabel": "Azioni di selezione"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Prevenzione",
|
||||
"sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.",
|
||||
"addButton": "Aggiungi promemoria",
|
||||
"markDone": "Fatto",
|
||||
"delete": "Elimina",
|
||||
"disabledBadge": "Disattivato",
|
||||
"nextDue": {
|
||||
"none": "Non pianificato",
|
||||
"today": "In scadenza oggi",
|
||||
"tomorrow": "In scadenza domani",
|
||||
"inDays": "Tra {days} giorni"
|
||||
},
|
||||
"overdueByDays": "In ritardo di {days} giorni",
|
||||
"cadence": {
|
||||
"everyNDays": "Ogni {days} giorni",
|
||||
"custom": "Pianificazione personalizzata"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "Presso: {location}"
|
||||
},
|
||||
"types": {
|
||||
"WEIGHT": "Peso",
|
||||
"BLOOD_PRESSURE_SYS": "Pressione sanguigna",
|
||||
"PULSE": "Battito",
|
||||
"BLOOD_GLUCOSE": "Glicemia",
|
||||
"OXYGEN_SATURATION": "Saturazione di ossigeno",
|
||||
"BODY_FAT": "Grasso corporeo",
|
||||
"BODY_TEMPERATURE": "Temperatura corporea"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ancora nessun promemoria",
|
||||
"description": "Aggiungi un promemoria per misurare regolarmente la pressione o pianificare un esame del sangue annuale."
|
||||
},
|
||||
"form": {
|
||||
"label": "Etichetta",
|
||||
"labelPlaceholder": "es. Misurare la pressione",
|
||||
"kind": "Tipo",
|
||||
"kindType": "Collegato a una misurazione",
|
||||
"kindFreeText": "Controllo a testo libero",
|
||||
"measurementType": "Misurazione",
|
||||
"cadence": "Ripeti",
|
||||
"notifyHour": "Ricorda alle",
|
||||
"location": "Dove (facoltativo)",
|
||||
"locationPlaceholder": "es. Medico, Laboratorio",
|
||||
"save": "Salva"
|
||||
},
|
||||
"pushTitle": "Promemoria di prevenzione",
|
||||
"pushBody": "È ora di: {label}",
|
||||
"pushLocation": "(presso {location})"
|
||||
}
|
||||
}
|
||||
@@ -6208,5 +6208,56 @@
|
||||
"deleteN": "Usuń ({count})",
|
||||
"clearSelection": "Wyczyść zaznaczenie",
|
||||
"selectionBarLabel": "Akcje zaznaczenia"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Profilaktyka",
|
||||
"sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.",
|
||||
"addButton": "Dodaj przypomnienie",
|
||||
"markDone": "Gotowe",
|
||||
"delete": "Usuń",
|
||||
"disabledBadge": "Wył.",
|
||||
"nextDue": {
|
||||
"none": "Niezaplanowane",
|
||||
"today": "Termin dzisiaj",
|
||||
"tomorrow": "Termin jutro",
|
||||
"inDays": "Za {days} dni"
|
||||
},
|
||||
"overdueByDays": "Zaległe od {days} dni",
|
||||
"cadence": {
|
||||
"everyNDays": "Co {days} dni",
|
||||
"custom": "Własny harmonogram"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "U: {location}"
|
||||
},
|
||||
"types": {
|
||||
"WEIGHT": "Waga",
|
||||
"BLOOD_PRESSURE_SYS": "Ciśnienie krwi",
|
||||
"PULSE": "Tętno",
|
||||
"BLOOD_GLUCOSE": "Glukoza we krwi",
|
||||
"OXYGEN_SATURATION": "Saturacja tlenem",
|
||||
"BODY_FAT": "Tkanka tłuszczowa",
|
||||
"BODY_TEMPERATURE": "Temperatura ciała"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Brak przypomnień",
|
||||
"description": "Dodaj przypomnienie, aby regularnie mierzyć ciśnienie krwi lub zaplanować coroczne badanie krwi."
|
||||
},
|
||||
"form": {
|
||||
"label": "Etykieta",
|
||||
"labelPlaceholder": "np. Zmierz ciśnienie krwi",
|
||||
"kind": "Typ",
|
||||
"kindType": "Powiązane z pomiarem",
|
||||
"kindFreeText": "Sprawdzenie tekstowe",
|
||||
"measurementType": "Pomiar",
|
||||
"cadence": "Powtarzaj",
|
||||
"notifyHour": "Przypomnij o",
|
||||
"location": "Gdzie (opcjonalnie)",
|
||||
"locationPlaceholder": "np. Lekarz, Laboratorium",
|
||||
"save": "Zapisz"
|
||||
},
|
||||
"pushTitle": "Przypomnienie profilaktyczne",
|
||||
"pushBody": "Czas na: {label}",
|
||||
"pushLocation": "(u {location})"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
|
||||
* feature surface for "wann muss ich was wo machen". Auth-gated; the
|
||||
* section component owns the list + create flow.
|
||||
*/
|
||||
export default function VorsorgePage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-6">
|
||||
<VorsorgeSection enabled={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
type MeasurementReminder,
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_FAT",
|
||||
"BODY_TEMPERATURE",
|
||||
] as const;
|
||||
|
||||
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function relativeDueKey(
|
||||
nextDueAt: string | null,
|
||||
now: number,
|
||||
): { key: string; days: number } {
|
||||
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
|
||||
const due = new Date(nextDueAt).getTime();
|
||||
// Compare calendar-day deltas so "today" / "in 1 day" read cleanly.
|
||||
const deltaDays = Math.round((due - 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 };
|
||||
}
|
||||
|
||||
export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders(enabled);
|
||||
const { create, remove, satisfy } = useMeasurementReminderMutations();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Form state.
|
||||
const [label, setLabel] = useState("");
|
||||
const [kind, setKind] = useState<"type" | "freeText">("type");
|
||||
const [measurementType, setMeasurementType] =
|
||||
useState<string>("BLOOD_PRESSURE_SYS");
|
||||
const [intervalDays, setIntervalDays] = useState<number>(7);
|
||||
const [notifyHour, setNotifyHour] = useState<number>(9);
|
||||
const [location, setLocation] = useState("");
|
||||
|
||||
// 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
|
||||
// mount-time snapshot is plenty fresh.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
function resetForm() {
|
||||
setLabel("");
|
||||
setKind("type");
|
||||
setMeasurementType("BLOOD_PRESSURE_SYS");
|
||||
setIntervalDays(7);
|
||||
setNotifyHour(9);
|
||||
setLocation("");
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
create.mutate(
|
||||
{
|
||||
label: trimmed,
|
||||
measurementType: kind === "type" ? measurementType : null,
|
||||
intervalDays,
|
||||
notifyHour,
|
||||
location: location.trim() || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="vorsorge-section-title"
|
||||
className="space-y-4"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="vorsorge-section-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("measurementReminders.sectionTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.sectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={showForm ? "outline" : "default"}
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("measurementReminders.addButton")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-label">
|
||||
{t("measurementReminders.form.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vorsorge-label"
|
||||
value={label}
|
||||
maxLength={120}
|
||||
placeholder={t("measurementReminders.form.labelPlaceholder")}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-kind">
|
||||
{t("measurementReminders.form.kind")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="vorsorge-kind"
|
||||
value={kind}
|
||||
onChange={(e) =>
|
||||
setKind(e.target.value as "type" | "freeText")
|
||||
}
|
||||
>
|
||||
<option value="type">
|
||||
{t("measurementReminders.form.kindType")}
|
||||
</option>
|
||||
<option value="freeText">
|
||||
{t("measurementReminders.form.kindFreeText")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{kind === "type" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-type">
|
||||
{t("measurementReminders.form.measurementType")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="vorsorge-type"
|
||||
value={measurementType}
|
||||
onChange={(e) => setMeasurementType(e.target.value)}
|
||||
>
|
||||
{TYPE_OPTIONS.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`measurementReminders.types.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-interval">
|
||||
{t("measurementReminders.form.cadence")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="vorsorge-interval"
|
||||
value={String(intervalDays)}
|
||||
onChange={(e) => setIntervalDays(Number(e.target.value))}
|
||||
>
|
||||
{INTERVAL_PRESETS.map((days) => (
|
||||
<option key={days} value={days}>
|
||||
{t("measurementReminders.cadence.everyNDays", { days })}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-hour">
|
||||
{t("measurementReminders.form.notifyHour")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="vorsorge-hour"
|
||||
value={String(notifyHour)}
|
||||
onChange={(e) => setNotifyHour(Number(e.target.value))}
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<option key={h} value={h}>
|
||||
{`${h.toString().padStart(2, "0")}:00`}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-location">
|
||||
{t("measurementReminders.form.location")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vorsorge-location"
|
||||
value={location}
|
||||
maxLength={200}
|
||||
placeholder={t(
|
||||
"measurementReminders.form.locationPlaceholder",
|
||||
)}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!label.trim() || create.isPending}
|
||||
>
|
||||
{t("measurementReminders.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
|
||||
<EmptyState
|
||||
title={t("measurementReminders.empty.title")}
|
||||
description={t("measurementReminders.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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)}
|
||||
busy={satisfy.isPending || remove.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VorsorgeCard({
|
||||
reminder,
|
||||
now,
|
||||
onSatisfy,
|
||||
onRemove,
|
||||
busy,
|
||||
}: {
|
||||
reminder: MeasurementReminder;
|
||||
now: number;
|
||||
onSatisfy: () => void;
|
||||
onRemove: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const cadence =
|
||||
reminder.intervalDays != null
|
||||
? t("measurementReminders.cadence.everyNDays", {
|
||||
days: reminder.intervalDays,
|
||||
})
|
||||
: reminder.rrule
|
||||
? t("measurementReminders.cadence.custom")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<li>
|
||||
{/* NEUTRAL card — no status-driven colour. The due state reads only
|
||||
through the discreet badge below. */}
|
||||
<Card>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-6">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium">{reminder.label}</p>
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
{cadence}
|
||||
</p>
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<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="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
disabled={busy}
|
||||
aria-label={t("measurementReminders.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.17.1 — data hook for Vorsorge (measurement) reminders.
|
||||
*
|
||||
* Wraps the `/api/measurement-reminders` CRUD + satisfy surface behind
|
||||
* TanStack Query. Every mutation invalidates the one
|
||||
* `measurementReminders()` root so the section list and the dashboard
|
||||
* tile repaint in lockstep. The DTO is the server-authoritative shape —
|
||||
* the client renders `nextDueAt` as-is, never recomputing.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface MeasurementReminder {
|
||||
id: string;
|
||||
label: string;
|
||||
measurementType: string | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: string | null;
|
||||
notifyHour: number;
|
||||
location: string | null;
|
||||
nextDueAt: string | null;
|
||||
lastSatisfiedAt: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateMeasurementReminderBody {
|
||||
label: string;
|
||||
measurementType?: string | null;
|
||||
intervalDays?: number | null;
|
||||
rrule?: string | null;
|
||||
anchorDate?: string | null;
|
||||
notifyHour?: number;
|
||||
location?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateMeasurementReminderBody = Partial<
|
||||
CreateMeasurementReminderBody
|
||||
>;
|
||||
|
||||
const BASE = "/api/measurement-reminders";
|
||||
|
||||
export function useMeasurementReminders(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.measurementReminders(),
|
||||
queryFn: () => apiGet<MeasurementReminder[]>(BASE),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMeasurementReminderMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() });
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderCreate(),
|
||||
mutationFn: (body: CreateMeasurementReminderBody) =>
|
||||
apiPost<MeasurementReminder>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderUpdate(),
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: string;
|
||||
body: UpdateMeasurementReminderBody;
|
||||
}) => apiPatch<MeasurementReminder>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const satisfy = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderSatisfy(),
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<MeasurementReminder>(`${BASE}/${id}/satisfy`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, satisfy };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { dashboardKeys } from "./dashboard";
|
||||
import { insightsKeys } from "./insights";
|
||||
import { integrationKeys } from "./integrations";
|
||||
import { measurementKeys } from "./measurements";
|
||||
import { measurementReminderKeys } from "./measurement-reminders";
|
||||
import { medicationKeys } from "./medications";
|
||||
import { moodKeys } from "./mood";
|
||||
import { settingsKeys } from "./settings";
|
||||
@@ -40,6 +41,7 @@ export const queryKeys = {
|
||||
...workoutKeys,
|
||||
...settingsKeys,
|
||||
...cycleKeys,
|
||||
...measurementReminderKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Query keys — Vorsorge (measurement) reminders (v1.17.1).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const measurementReminderKeys = {
|
||||
/**
|
||||
* The owner's Vorsorge reminder list. A create / edit / delete / satisfy
|
||||
* mutation invalidates this root so the section + the dashboard tile
|
||||
* repaint in lockstep.
|
||||
*/
|
||||
measurementReminders: () => ["measurement-reminders"] as const,
|
||||
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
|
||||
measurementReminderCreate: () =>
|
||||
["measurement-reminders", "create"] as const,
|
||||
measurementReminderUpdate: () =>
|
||||
["measurement-reminders", "update"] as const,
|
||||
measurementReminderDelete: () =>
|
||||
["measurement-reminders", "delete"] as const,
|
||||
measurementReminderSatisfy: () =>
|
||||
["measurement-reminders", "satisfy"] as const,
|
||||
};
|
||||
Reference in new issue
Block a user