Merge v189/vorsorge into release/v1.18.9

Drop the Vorsorge trend strip; show last-completed as relative today/yesterday
via a shared helper; render next-due status as discreet coloured inline text.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:55:16 +02:00
commit 9e1a256525
11 files changed
+69 -189

No files matched your search

+1 -8
View File
@@ -6451,14 +6451,7 @@
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
"openLink": "Vorsorge öffnen"
},
"showMeasurements": "Messergebnisse anzeigen",
"trendStrip": {
"label": "Verlauf der letzten 7 Tage",
"summary": "Letzter Wert {value}, {direction} über 7 Tage.",
"rising": "steigend",
"falling": "fallend",
"steady": "stabil"
}
"showMeasurements": "Messergebnisse anzeigen"
},
"moduleList": {
"viewToggleLabel": "Ansicht",
+1 -8
View File
@@ -6451,14 +6451,7 @@
"empty": "No upcoming preventive-care reminders.",
"openLink": "Open preventive care"
},
"showMeasurements": "Show measurements",
"trendStrip": {
"label": "Last 7 days",
"summary": "Latest value {value}, {direction} over 7 days.",
"rising": "rising",
"falling": "falling",
"steady": "steady"
}
"showMeasurements": "Show measurements"
},
"moduleList": {
"viewToggleLabel": "View",
+1 -8
View File
@@ -6451,14 +6451,7 @@
"empty": "No hay recordatorios de prevención próximos.",
"openLink": "Abrir prevención"
},
"showMeasurements": "Ver mediciones",
"trendStrip": {
"label": "Últimos 7 días",
"summary": "Último valor {value}, {direction} en 7 días.",
"rising": "en aumento",
"falling": "en descenso",
"steady": "estable"
}
"showMeasurements": "Ver mediciones"
},
"moduleList": {
"viewToggleLabel": "Vista",
+1 -8
View File
@@ -6451,14 +6451,7 @@
"empty": "Aucun rappel de prévention à venir.",
"openLink": "Ouvrir la prévention"
},
"showMeasurements": "Afficher les mesures",
"trendStrip": {
"label": "7 derniers jours",
"summary": "Dernière valeur {value}, {direction} sur 7 jours.",
"rising": "en hausse",
"falling": "en baisse",
"steady": "stable"
}
"showMeasurements": "Afficher les mesures"
},
"moduleList": {
"viewToggleLabel": "Affichage",
+1 -8
View File
@@ -6451,14 +6451,7 @@
"empty": "Nessun promemoria di prevenzione in arrivo.",
"openLink": "Apri prevenzione"
},
"showMeasurements": "Mostra le misurazioni",
"trendStrip": {
"label": "Ultimi 7 giorni",
"summary": "Ultimo valore {value}, {direction} su 7 giorni.",
"rising": "in aumento",
"falling": "in calo",
"steady": "stabile"
}
"showMeasurements": "Mostra le misurazioni"
},
"moduleList": {
"viewToggleLabel": "Vista",
+1 -8
View File
@@ -6451,14 +6451,7 @@
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
"openLink": "Otwórz profilaktykę"
},
"showMeasurements": "Pokaż pomiary",
"trendStrip": {
"label": "Ostatnie 7 dni",
"summary": "Ostatnia wartość {value}, {direction} w ciągu 7 dni.",
"rising": "rosnący",
"falling": "malejący",
"steady": "stabilny"
}
"showMeasurements": "Pokaż pomiary"
},
"moduleList": {
"viewToggleLabel": "Widok",
@@ -26,7 +26,6 @@ import { ListRow } from "@/components/ui/list-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { VorsorgeTrendStrip } from "@/components/measurement-reminders/vorsorge-trend-strip";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -136,12 +135,6 @@ export function VorsorgeDashboardCard() {
days: due.days,
})}
</Badge>
{/* v1.18.7 (Wave E) — discreet 7-day strip under the
metric context; renders nothing for a free-text
reminder or a too-thin window. */}
<VorsorgeTrendStrip
measurementType={reminder.measurementType}
/>
</div>
<Button
type="button"
@@ -39,6 +39,7 @@ import {
} from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { cn } from "@/lib/utils";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -71,7 +72,6 @@ import { Progress } from "@/components/ui/progress";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { VorsorgeTrendStrip } from "@/components/measurement-reminders/vorsorge-trend-strip";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import {
useMeasurementReminders,
@@ -929,20 +929,37 @@ function VorsorgeCard({
// (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The
// "next" value carries the discreet neutral due badge; the "last" value is
// the last satisfied date.
// v1.18.6.1 — the due/overdue status renders as plain TEXT (not a chip),
// and the "zuletzt erledigt" line ALWAYS renders, falling back to "—" when
// there is no last-done value — mirroring the medication card's next/last
// text rows.
// v1.18.9 (#39) — "zuletzt erledigt" reads as a RELATIVE date in the
// medication-card grammar (today → "Heute", yesterday → "Gestern", else the
// absolute date) through the shared `relativeCalendarDate` helper, reusing
// the same `medications.today` / `medications.yesterday` keys the medication
// card renders. Falls back to "—" when there is no last-done value.
const lastValue = reminder.lastSatisfiedAt
? fmt.date(new Date(reminder.lastSatisfiedAt))
? relativeCalendarDate(reminder.lastSatisfiedAt, t, (d) => fmt.date(d))
: "—";
// v1.18.9 (#39) — the next-due status reads as DISCREET COLOURED TEXT (never
// a card-background tint): due today → green, overdue → orange, otherwise the
// normal muted-resolved date. The card surface stays neutral per the house
// rule; only this inline label carries the status hue.
const dueIsToday = due.key === "nextDue.today";
const dueIsOverdue = due.key === "overdueByDays";
const nextLastSlot = (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("measurementReminders.nextDueLabel")}
</span>
<span className="text-foreground text-right">
<span
className={cn(
"text-right",
dueIsToday
? "text-success font-medium"
: dueIsOverdue
? "text-warning font-medium"
: "text-foreground",
)}
>
{t(`measurementReminders.${due.key}`, { days: due.days })}
</span>
</div>
@@ -972,12 +989,6 @@ function VorsorgeCard({
<CardContent className="flex h-full flex-col space-y-3.5">
{nextLastSlot}
{/* v1.18.7 (Wave E) — discreet 7-day strip of the metric's last
readings, sitting under the metric context like the dashboard
tiles. Renders nothing for a free-text reminder or a too-thin
window, so the card height stays stable when absent. */}
<VorsorgeTrendStrip measurementType={reminder.measurementType} />
{/* v1.18.6 (MOD-06) — light gamification: progress through the
current interval toward next-due, reusing the medication card's
Progress building block. Honest "on track / due" signal without
@@ -1,111 +0,0 @@
"use client";
/**
* v1.18.7 (Wave E) — a small, DISCREET 7-day trend strip for a Vorsorge
* card. Renders the reminder metric's last seven readings as seven quiet
* bars (newest → rightmost), heights normalised across the window. The
* colour is intentionally muted (`bg-muted-foreground/30`) so the strip
* reads as calm context sitting under the metric — never an alarming
* status signal, in keeping with the no-loud-colour card ethos.
*
* Pulls the bounded last-7 readings for one `MeasurementType` through the
* existing measurements list endpoint (a single `limit=7` read per card,
* cached by type) — no new heavy aggregate query. A free-text reminder
* (no `measurementType`) renders nothing; a metric with fewer than two
* readings in the window renders nothing (a single dot is no trend).
*/
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface RecentMeasurement {
value: number;
measuredAt: string;
}
/**
* Bounded last-7 read for one measurement type, sorted newest-first by the
* server. Re-sorted oldest → newest here so the bars read left (older) to
* right (newest). Shares the `measurements` invalidation prefix so a fresh
* reading (e.g. completing this very reminder) repaints the strip.
*/
function useRecentValues(measurementType: string | null, enabled: boolean) {
return useQuery({
queryKey: queryKeys.measurementRecentValues(measurementType ?? ""),
queryFn: async () => {
const params = new URLSearchParams();
params.set("type", measurementType as string);
params.set("limit", "7");
params.set("sortBy", "measuredAt");
params.set("sortDir", "desc");
const res = await apiGet<{ measurements: RecentMeasurement[] }>(
`/api/measurements?${params}`,
);
return res.measurements.slice().reverse();
},
enabled: enabled && measurementType != null,
});
}
export function VorsorgeTrendStrip({
measurementType,
enabled = true,
}: {
measurementType: string | null;
enabled?: boolean;
}) {
const { t } = useTranslations();
const { data } = useRecentValues(measurementType, enabled);
// Nothing to show for a free-text reminder or a too-thin window.
if (measurementType == null) return null;
const readings = data ?? [];
if (readings.length < 2) return null;
const values = readings.map((r) => r.value);
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
// 7-day direction from first → last reading in the window. Latest value
// and direction feed the sr-only summary so a screen reader gets the
// trend, not just the generic "Last 7 days" label.
const latest = values[values.length - 1];
const delta = latest - values[0];
const direction =
delta > 0
? t("measurementReminders.trendStrip.rising")
: delta < 0
? t("measurementReminders.trendStrip.falling")
: t("measurementReminders.trendStrip.steady");
return (
<div
className="flex h-6 items-end gap-0.5"
role="img"
aria-label={t("measurementReminders.trendStrip.label")}
>
<span className="sr-only">
{t("measurementReminders.trendStrip.summary", {
value: latest,
direction,
})}
</span>
{readings.map((r, i) => {
// Floor at ~18% so a flat series still reads as bars, not a line.
const pct = 0.18 + ((r.value - min) / span) * 0.82;
return (
<span
key={r.measuredAt}
data-index={i}
className="bg-muted-foreground/30 w-1.5 rounded-sm"
style={{ height: `${Math.round(pct * 100)}%` }}
aria-hidden="true"
/>
);
})}
</div>
);
}
+39
View File
@@ -47,3 +47,42 @@ export function formatRelativeTime(
{ count: days },
);
}
/**
* v1.18.9 — date-only relative label, shared with the medication card grammar.
*
* Buckets a calendar date against "now": same day → `medications.today`
* ("Heute"), the day before → `medications.yesterday` ("Gestern"), otherwise
* the absolute date through the caller's locale formatter. The today /
* yesterday i18n keys are the SAME ones the medication card renders for its
* last-taken line, so a Vorsorge "zuletzt erledigt" reads identically.
*
* Day-bucketing uses an `en-CA` (YYYY-MM-DD) day key — locale-independent and
* string-comparable — formatted in the supplied IANA `timeZone` so the
* boundary follows the user's zone, not the server's. `formatDate` renders the
* absolute fallback (pass the locale `fmt.date`).
*/
export function relativeCalendarDate(
iso: string,
t: (key: string, params?: Record<string, string | number>) => string,
formatDate: (value: Date) => string,
timeZone?: string,
): string {
const target = new Date(iso);
if (Number.isNaN(target.getTime())) return "";
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const targetDay = dayFormatter.format(target);
if (targetDay === dayFormatter.format(now)) return t("medications.today");
if (targetDay === dayFormatter.format(yesterday))
return t("medications.yesterday");
return formatDate(target);
}
-10
View File
@@ -145,16 +145,6 @@ export const measurementKeys = {
personalRecordsByMetric: (metric: string) =>
["personal-records", "by-metric", metric] as const,
/**
* v1.18.7 (Wave E) — bounded last-7 readings for one `MeasurementType`,
* powering the discreet 7-day trend strip on a Vorsorge card. Rides under
* the `["measurements"]` prefix so it lands in `measurementDependentKeys`
* and a fresh reading (e.g. satisfying the very reminder) repaints the
* strip. Keyed by type so each card caches its own thin window.
*/
measurementRecentValues: (type: string) =>
["measurements", "recent-values", type] as const,
/**
* v1.18.6 — batched dashboard daily series
* (`GET /api/measurements/series-batch`). Shares the `chart-data`