Merge wave-vorsorge: 7-day strip + filtered-measurements deep-link

This commit is contained in:
Marc-André Bombeck committed 2026-06-19 19:45:36 +02:00
commit deccdaca0a
13 files changed
+205 -9

No files matched your search

+4
View File
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
"openLink": "Vorsorge öffnen"
},
"showMeasurements": "Messergebnisse anzeigen",
"trendStrip": {
"label": "Verlauf der letzten 7 Tage"
}
},
"moduleList": {
+4
View File
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "No upcoming preventive-care reminders.",
"openLink": "Open preventive care"
},
"showMeasurements": "Show measurements",
"trendStrip": {
"label": "Last 7 days"
}
},
"moduleList": {
+4
View File
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "No hay recordatorios de prevención próximos.",
"openLink": "Abrir prevención"
},
"showMeasurements": "Ver mediciones",
"trendStrip": {
"label": "Últimos 7 días"
}
},
"moduleList": {
+4
View File
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Aucun rappel de prévention à venir.",
"openLink": "Ouvrir la prévention"
},
"showMeasurements": "Afficher les mesures",
"trendStrip": {
"label": "7 derniers jours"
}
},
"moduleList": {
+4
View File
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Nessun promemoria di prevenzione in arrivo.",
"openLink": "Apri prevenzione"
},
"showMeasurements": "Mostra le misurazioni",
"trendStrip": {
"label": "Ultimi 7 giorni"
}
},
"moduleList": {
+4
View File
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
"openLink": "Otwórz profilaktykę"
},
"showMeasurements": "Pokaż pomiary",
"trendStrip": {
"label": "Ostatnie 7 dni"
}
},
"moduleList": {
+24 -5
View File
@@ -10,6 +10,7 @@ import {
resolveAddToken,
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -41,6 +42,18 @@ export default function MeasurementsPage() {
// effect, so that path stays render-driven.
const addParam = searchParams.get("add");
const initialAdd = addParam ? resolveAddToken(addParam) : null;
// v1.18.7 (Wave E) — a `?type=<MEASUREMENT_TYPE>` deep link (e.g. the
// Vorsorge card's "Show measurements") seeds the list's type filter on
// first paint. Captured once via a lazy initializer so the value is
// stable even after the effect below strips the query string; an unknown
// token is dropped (validated against the canonical type-label map), so a
// stale link never traps the user on a broken filter.
const typeParam = searchParams.get("type");
const [initialType] = useState<string | undefined>(() =>
typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS
? typeParam
: undefined,
);
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
const [defaultType, setDefaultType] = useState<string | undefined>(
() => initialAdd ?? undefined,
@@ -82,11 +95,14 @@ export default function MeasurementsPage() {
}
// Drop the query string so the back-button leaves the user on
// `/measurements` rather than re-opening the dialog. Effect-driven
// (client-only) because `router.replace` is not callable during SSR.
// `/measurements` rather than re-opening the dialog (or re-seeding the
// type filter). The type filter is already seeded into the list's own
// state via `initialType`, so stripping the param doesn't lose it.
// Effect-driven (client-only) because `router.replace` is not callable
// during SSR.
useEffect(() => {
if (addParam) router.replace("/measurements");
}, [addParam, router]);
if (addParam || typeParam) router.replace("/measurements");
}, [addParam, typeParam, router]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
@@ -157,7 +173,10 @@ export default function MeasurementsPage() {
/>
</ResponsiveSheet>
<MeasurementList onAddFirst={() => setDialogOpen(true)} />
<MeasurementList
onAddFirst={() => setDialogOpen(true)}
initialType={initialType}
/>
</div>
);
}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
@@ -27,8 +28,16 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
import { VorsorgeSection } from "../vorsorge-section";
function render(node: React.ReactNode) {
// v1.18.7 (Wave E) — a measurement-linked card now mounts the 7-day
// trend strip, which reads via TanStack Query; wrap in a client so the
// static-markup render resolves (the query stays idle on the server).
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -25,6 +25,7 @@ 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 { VorsorgeTrendStrip } from "@/components/measurement-reminders/vorsorge-trend-strip";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -122,13 +123,19 @@ export function VorsorgeDashboardCard() {
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">
<div className="min-w-0 space-y-1">
<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>
{/* 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"
@@ -65,12 +65,13 @@ import {
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 { LineChart, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { NativeSelect } from "@/components/ui/native-select";
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/MedicationCardHeader";
import {
useMeasurementReminders,
@@ -805,6 +806,21 @@ function VorsorgeCard({
<Pencil className="mr-2 h-4 w-4" />
{t("measurementReminders.edit")}
</DropdownMenuItem>
{/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to
this reminder's type. Only for measurement-linked reminders; a
free-text reminder has no readings to show. */}
{isLinked && (
<DropdownMenuItem asChild>
<Link
href={`/measurements?type=${encodeURIComponent(
reminder.measurementType as string,
)}`}
>
<LineChart className="mr-2 h-4 w-4" />
{t("measurementReminders.showMeasurements")}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@@ -959,6 +975,12 @@ 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
@@ -0,0 +1,93 @@
"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()
.map((m) => m.value);
},
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 values = data ?? [];
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
return (
<div
className="flex h-6 items-end gap-0.5"
role="img"
aria-label={t("measurementReminders.trendStrip.label")}
>
{values.map((v, i) => {
// Floor at ~18% so a flat series still reads as bars, not a line.
const pct = 0.18 + ((v - min) / span) * 0.82;
return (
<span
key={i}
className="bg-muted-foreground/30 w-1.5 rounded-sm"
style={{ height: `${Math.round(pct * 100)}%` }}
/>
);
})}
</div>
);
}
@@ -179,6 +179,13 @@ interface MeasurementListProps {
* scoped to this type; the count caption still shows.
*/
lockedType?: string;
/**
* v1.18.7 (Wave E) — seed the (still user-editable) type filter from a
* `?type=<MEASUREMENT_TYPE>` deep link, e.g. the Vorsorge card's "Show
* measurements" action. Unlike `lockedType` the full filter rail stays
* visible so the user can widen or change the filter afterwards.
*/
initialType?: string;
}
const PAGE_SIZE = 25;
@@ -263,6 +270,7 @@ export function MeasurementList({
onEdit,
onAddFirst,
lockedType,
initialType,
}: MeasurementListProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -270,7 +278,11 @@ export function MeasurementList({
const queryClient = useQueryClient();
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
const [typeFilter, setTypeFilterRaw] = useState<string>(lockedType ?? "ALL");
// v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
// same filter but keeps the rail interactive so the user can change it.
const [typeFilter, setTypeFilterRaw] = useState<string>(
lockedType ?? initialType ?? "ALL",
);
// v1.15.13 — management-list source filter + optional date range.
// `ALL` clears the source filter; empty date strings clear the bound.
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
+10
View File
@@ -126,6 +126,16 @@ export const measurementKeys = {
measurementDiversity: (type: string) =>
["measurement-diversity", type] 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`