mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(vorsorge): add 7-day trend strip + measurements deep link to reminder cards
Enrich the Vorsorge cards with a discreet 7-day trend strip and a path into the underlying readings. - A quiet seven-bar strip of the reminder metric's last seven readings sits under the metric context on both the dashboard summary card and the full-grid cards, mirroring how a dashboard tile carries a small trend. Neutral muted bars, no status colour. A free-text reminder or a metric with fewer than two readings shows nothing, so card height stays stable. The strip reads a bounded last-7 window per type through the existing measurements endpoint, cached by type under the measurements invalidation prefix. - The card kebab gains a 'Show measurements' item for measurement-linked reminders that opens /measurements pre-filtered to that type. - The measurements list accepts a ?type=<MEASUREMENT_TYPE> deep link that seeds the existing client-side type filter while leaving the rail interactive; unknown tokens are dropped. The ?add= behaviour is unchanged. - New labels added across all six locales.
This commit is contained in:
1 parent
f931f2f5c2
commit
c7425ca426
13 files changed
+205
-9
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user