chore(release): v1.4.28

Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 03:17:00 +02:00
1 parent 4f09c3f862
commit 2d111fcb24
136 files changed
+5731 -8462

No files matched your search

@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Target, Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.28 R3a FB-C1 + FB-C2 — BD-Zielbereich tile shape parity.
*
* Until v1.4.27 the dashboard mounted the BD tile through a special
* branch that:
* 1. Synthesised a fake `slope30 = bpTrendDelta / 30` so the arrow
* could move (the result was a small float like 1.1 that the
* TrendCard's downstream formatter pipeline could render as
* "1.1." — the maintainer's screenshot regression).
* 2. Passed `avgAllTime` so a third sub-row showed the all-time
* percentage — the BD tile was the only consumer of that prop.
*
* Both customisations retire in v1.4.28: the BD tile now mounts
* through the same TrendCard contract as Weight / BP / Pulse and the
* `avgAllTime*` prop family drops entirely. This spec is the
* regression lock against the divergent shape returning.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
it("renders a numeric percent headline, never a date-shaped literal", () => {
const html = render(
<TrendCard
label="BP in target"
latest={87}
unit="%"
avg7={90}
avg30={87}
slope30={null}
trend7Delta={3}
icon={Target}
directionSentiment="up-good"
/>,
);
// The headline value-row must include "%" and an integer/percent
// string — never a `d.d.` artefact from the legacy synthetic-slope
// formatter chain.
expect(html).toContain('data-slot="trend-card-value-row"');
const valueRowMatch = html.match(
/data-slot="trend-card-value-row"[^>]*>([\s\S]*?)<\/div>/,
);
expect(valueRowMatch).not.toBeNull();
const valueText = valueRowMatch![1];
expect(valueText).toContain("%");
expect(valueText).not.toMatch(/\d{1,2}\.\d{1,2}\.</);
});
it("ships the same DOM shape as the Weight tile (no all-time slot)", () => {
const bdHtml = render(
<TrendCard
label="BP in target"
latest={87}
unit="%"
avg7={90}
avg30={87}
slope30={null}
trend7Delta={3}
icon={Target}
directionSentiment="up-good"
/>,
);
const weightHtml = render(
<TrendCard
label="Weight"
latest={75.5}
unit="kg"
avg7={76.0}
avg30={75.8}
slope30={null}
trend7Delta={-0.2}
icon={Activity}
directionSentiment="up-bad"
/>,
);
// Same primitive slots on both tiles.
for (const slot of [
"trend-card-value-row",
]) {
expect(bdHtml).toContain(`data-slot="${slot}"`);
expect(weightHtml).toContain(`data-slot="${slot}"`);
}
// The retired all-time slot must NOT render on either tile.
expect(bdHtml).not.toContain('data-slot="trend-card-all-time"');
expect(weightHtml).not.toContain('data-slot="trend-card-all-time"');
});
it("renders a dash headline when the metric has no data yet", () => {
const html = render(
<TrendCard
label="BP in target"
latest={null}
unit="%"
avg7={null}
avg30={null}
slope30={null}
icon={Target}
directionSentiment="up-good"
/>,
);
expect(html).toContain('data-slot="trend-card-value-row"');
expect(html).toContain("—");
});
});
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.28 R3a FB-D2 (R1.2 H0) — assert the HealthChart fetcher passes
* a bounded `from` / `to` window to `/api/measurements` instead of
* walking the full history with a `while (true)` loop.
*
* The test intercepts the underlying `useQuery` so the assertion can
* lock down the query key shape AND the fetch URL the queryFn invokes.
* If a future refactor re-introduces an unbounded walk the queryFn
* mock will fire with no `from=` / `to=` segment and the test fails.
*/
const fetchCalls: string[] = [];
vi.mock("@tanstack/react-query", () => ({
useQuery: ({
queryFn,
queryKey,
}: {
queryKey: unknown[];
queryFn: () => Promise<unknown>;
staleTime?: number;
gcTime?: number;
}) => {
// Surface the query key so the test can introspect it.
(globalThis as unknown as { __lastQueryKey?: unknown[] }).__lastQueryKey =
queryKey;
// Fire the queryFn synchronously (it will call our fetch stub).
void queryFn();
return { data: [], isLoading: false };
},
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: null,
isLoading: false,
}),
}));
beforeEach(() => {
fetchCalls.length = 0;
global.fetch = vi.fn(async (url: RequestInfo | URL) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify({ data: { measurements: [], meta: { total: 0 } } }),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
});
describe("<HealthChart> — bounded range fetch", () => {
it("sends from + to + a 5000-row cap to /api/measurements", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
// Wait a tick so the queryFn fetch resolves.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchCalls.length).toBeGreaterThan(0);
const measurementsUrl = fetchCalls.find((u) =>
u.includes("/api/measurements"),
);
expect(measurementsUrl).toBeDefined();
expect(measurementsUrl!).toMatch(/from=/);
expect(measurementsUrl!).toMatch(/to=/);
expect(measurementsUrl!).toMatch(/limit=5000/);
expect(measurementsUrl!).toMatch(/type=PULSE/);
// Guard against the legacy unbounded paginator returning.
expect(measurementsUrl!).not.toMatch(/offset=/);
});
it("re-keys the query when the range window changes", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
const key = (globalThis as unknown as { __lastQueryKey?: unknown[] })
.__lastQueryKey;
expect(key).toBeDefined();
// Last two slots are `from` / `to`. Both must be ISO date strings.
const isoRe = /\d{4}-\d{2}-\d{2}T/;
expect(String(key![key!.length - 2])).toMatch(isoRe);
expect(String(key![key!.length - 1])).toMatch(isoRe);
});
});
@@ -81,4 +81,29 @@ describe("<MoodChart> v1.4.18 clean-line revert", () => {
expect(src).not.toContain("mood-emoji-glyph");
expect(src).not.toMatch(/[\u{1F600}-\u{1F64F}]/u);
});
it("paints the rounded-md mini shell so the trends row shares one radius", async () => {
// D-H5 — the mood-chart mini sits next to two HealthChart mini
// tiles on `/insights`. HealthChart mini paints
// `bg-card border-border rounded-md border p-2`; mood mini rode
// the shadcn `<Card>` default `rounded-xl`, so the trends row
// showed two corner radii. The override on the Card wrapper now
// collapses the mood tile to `rounded-md` to match.
const { I18nProvider } = await import("@/lib/i18n/context");
const { MoodChart } = await import("../mood-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodChart mini />
</I18nProvider>,
);
// The mini wrapper paints data-slot="chart-mini" with rounded-md.
const wrapper = html.match(
/<div[^>]*data-slot="chart-mini"[^>]*class="([^"]+)"/,
);
expect(wrapper).not.toBeNull();
const cls = wrapper?.[1] ?? "";
expect(cls).toContain("rounded-md");
expect(cls).not.toMatch(/\brounded-xl\b/);
});
});
@@ -1,119 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Target } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.22 A2 — BD-Zielbereich tile feature parity.
*
* The BP-in-target tile now ships the same set of features every other
* tile already has:
* - the optional `avgAllTime` sub-value renders next to `7d` and
* `30d` so the long-arc number stays visible after v1.4.22 A1
* re-anchored the headline to the last-30-day window.
* - the trend arrow + 7-day-trend chip use a synthesised slope from
* the difference between the 7-day and 30-day in-target shares.
* - the comparison overlay flows through the same `compareBaseline`
* / `compareDelta` props the dashboard wires for every other tile.
*
* Other tiles leave `avgAllTime` undefined so the third sub-row never
* renders — pinned via the negative case at the bottom.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const baseProps = {
label: "BP in target",
latest: 50,
unit: "%",
avg7: 55,
avg30: 50,
slope30: null,
icon: Target,
directionSentiment: "up-good" as const,
};
describe("<TrendCard> avgAllTime sub-value (v1.4.22 A2)", () => {
it("renders the all-time label + value when `avgAllTime` is provided", () => {
const html = render(<TrendCard {...baseProps} avgAllTime={11} />);
// The slot is keyed via `data-slot` so callers can target it
// without scraping translated copy.
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).toContain("All-time");
// The headline (50 %) is the 30-day pct after v1.4.22 A1; the
// all-time number lives in the third sub-row. Both must render
// (the formatter emits a trailing `.0` for integer values via the
// shared `useFormatters().number(_, 1)` contract — same shape as
// every other tile uses).
expect(html).toContain(">50.0<");
expect(html).toContain(">11.0<");
});
it("paints the all-time value with the supplied color class", () => {
const html = render(
<TrendCard
{...baseProps}
avgAllTime={11}
avgAllTimeColorClass="text-dracula-orange"
/>,
);
expect(html).toContain("text-dracula-orange");
});
it("renders a dash for a null `avgAllTime` (no data yet) instead of a literal 'null'", () => {
const html = render(<TrendCard {...baseProps} avgAllTime={null} />);
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).toContain(">—<");
expect(html).not.toContain("null");
});
it("does NOT render the all-time slot when `avgAllTime` is undefined (other tiles untouched)", () => {
const html = render(<TrendCard {...baseProps} />);
expect(html).not.toContain('data-slot="trend-card-all-time"');
});
it("ships the trend arrow when a synthetic slope is provided", () => {
// Synthetic slope: 7d (55) above 30d (50) → up direction → green
// arrow under the up-good sentiment.
const html = render(
<TrendCard
{...baseProps}
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
avgAllTime={11}
/>,
);
expect(html).toContain("text-dracula-green");
});
it("ships the 7-day-trend chip when a delta is provided", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
trend7Delta={5}
avgAllTime={11}
/>,
);
// The TrendCard renders the delta as e.g. `(+5.0)` next to `7d:`.
expect(html).toMatch(/\(\+5\.0\)/);
});
it("ships the comparison overlay when comparison is active", () => {
const html = render(
<TrendCard
{...baseProps}
avgAllTime={11}
compareBaseline="lastMonth"
compareDelta={4}
/>,
);
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain("+4.0");
});
});
@@ -125,6 +125,9 @@ describe("<TrendCard> directionSentiment", () => {
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
// v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime`
// and the synthetic slope; assert the surviving shape (overflow
// wrap discipline, compare-delta callout) still ships.
const html = render(
<TrendCard
label="BP in target"
@@ -132,8 +135,7 @@ describe("<TrendCard> responsive layout", () => {
unit="%"
avg7={100}
avg30={50}
avgAllTime={11}
slope30={RISING}
slope30={null}
trend7Delta={50}
icon={Activity}
directionSentiment="up-good"
@@ -145,6 +147,6 @@ describe("<TrendCard> responsive layout", () => {
expect(html).toContain("min-w-0");
expect(html).toContain("[overflow-wrap:anywhere]");
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).not.toContain('data-slot="trend-card-all-time"');
});
});
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/**
* v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every
* `next/dynamic` chart import on the dashboard and the insights
* sub-pages.
*
* The chart components (`<HealthChart>`, `<MoodChart>`, …) live behind
* `dynamic(() => import("…"), { ssr: false })`. Pre-fix, the loading
* slot was empty: the page hydrated, the Recharts chunk fetched, and
* the layout collapsed/expanded as the chart arrived. On a cold visit
* over a slow network the gap was visible for several seconds (R1.2
* H4).
*
* This skeleton mirrors the same container that `health-chart.tsx`
* paints (rounded card, border, `p-4 md:p-6`) plus a faux header row
* (title + range tabs) and a chart band sized to the CSS variable the
* chart itself reads (`--chart-height` → 240 px mobile / 280 px md+).
* The cumulative box matches the loaded chart's height to within a
* pixel so the page does not jump on hydration.
*
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
* primitive's `animate-pulse` is suppressed via Tailwind's
* `motion-reduce:animate-none` modifier.
*/
export function ChartSkeleton({ className }: { className?: string }) {
return (
<div
data-slot="chart-skeleton"
role="status"
aria-busy="true"
aria-live="polite"
className={cn(
"bg-card border-border rounded-xl border p-4 md:p-6",
className,
)}
>
<span className="sr-only">Loading chart…</span>
{/* Header row — title + range tabs match the real chart's
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-12" />
<Skeleton className="h-6 w-12" />
<Skeleton className="h-6 w-12" />
<Skeleton className="h-6 w-12" />
</div>
</div>
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
contract `health-chart.tsx` paints at line 1087-1089. */}
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import dynamic from "next/dynamic";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
/**
* v1.4.28 (BK-F-M2) — single `next/dynamic` boundary for the
* canonical `<HealthChart>` component.
*
* Pre-fix, eight call sites (`src/app/page.tsx`, five insights
* sub-pages, `sleep-duration-chart.tsx`, `trends-row.tsx`, and
* `vo2-max-chart-row.tsx`) duplicated the same
* `dynamic(() => import("@/components/charts/health-chart"), { ... })`
* incantation. The duplication meant the loading skeleton + `ssr: false`
* flag had to be re-wired on every caller.
*
* Re-exporting through this module gives every consumer the same
* pre-configured `<ChartSkeleton>`-backed lazy boundary. Consumers
* still forward the same props they did before — the type contract
* mirrors `<HealthChart>` exactly via `dynamic()`'s own inference.
*/
export const HealthChartDynamic = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
+78 -45
View File
@@ -504,6 +504,40 @@ export function HealthChart({
const bmiDivisor =
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
// v1.4.28 FB-D2 (R1.2 H0) — derive a bounded date window from the
// active range selector so the chart fetches only what it will
// actually render. The legacy `while (true)` paginated walk pulled
// tens of thousands of pulse rows on every visit; the bounded
// window + the server-side aggregation hint keeps every navigation
// payload-flat regardless of underlying density.
//
// Comparison overlays (lastMonth / lastYear) extend the `from`
// boundary backwards by the shift distance so the prior-period
// slice rides the same fetch and `shiftDailySeriesForward` finds
// its input. "All" range (rangePoints === 0) defaults to a
// 365-day window; the chart's existing all-time UX already
// re-renders against whatever window we hand it.
const fetchWindow = useMemo(() => {
const to = new Date();
const windowDays = rangePoints > 0 ? rangePoints : 365;
const compareShift =
effectiveCompareBaseline === "lastMonth"
? 30
: effectiveCompareBaseline === "lastYear"
? 365
: 0;
const totalDays = windowDays + compareShift;
const from = new Date(to.getTime() - totalDays * 86_400_000);
// Use ISO strings so the cache key is stable across the day; the
// server treats `to` as the upper bound and the chart truncates to
// `rangePoints` on the client.
return {
from: from.toISOString(),
to: to.toISOString(),
windowDays: totalDays,
};
}, [rangePoints, effectiveCompareBaseline]);
const { data, isLoading } = useQuery({
queryKey: [
"chart-data",
@@ -514,12 +548,19 @@ export function HealthChart({
// user timezone, so re-key the cache when it changes. Without
// this, a tz change inside a session would render stale buckets.
userTimezone,
// v1.4.28 FB-D2 — bound the cache by the active fetch window so
// a range-tab change re-fetches the right slice rather than
// re-running the unbounded walk.
fetchWindow.from,
fetchWindow.to,
],
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
// navigation between insights sub-pages does not re-fire every
// chart's fetch. `gcTime` keeps the inactive cache for five
// minutes so a quick back-and-forth doesn't repay the cost.
staleTime: 60_000,
gcTime: 5 * 60_000,
queryFn: async () => {
const params = new URLSearchParams();
params.set("sortBy", "measuredAt");
params.set("sortDir", "asc");
const dailyAggregates = new Map<
string,
{
@@ -528,56 +569,48 @@ export function HealthChart({
}
>();
async function fetchAllMeasurementsByType(type: string) {
const pageSize = 500;
let offset = 0;
async function fetchMeasurementsByType(type: string) {
const typeParams = new URLSearchParams();
typeParams.set("type", type);
typeParams.set("sortBy", "measuredAt");
typeParams.set("sortDir", "asc");
typeParams.set("from", fetchWindow.from);
typeParams.set("to", fetchWindow.to);
typeParams.set("limit", "5000");
while (true) {
const typeParams = new URLSearchParams(params);
typeParams.set("type", type);
typeParams.set("limit", String(pageSize));
typeParams.set("offset", String(offset));
const res = await fetch(`/api/measurements?${typeParams}`);
if (!res.ok) return;
const res = await fetch(`/api/measurements?${typeParams}`);
if (!res.ok) break;
const json = await res.json();
const page = (json.data?.measurements ?? []) as MeasurementApiRow[];
const json = await res.json();
const page = (json.data?.measurements ?? []) as MeasurementApiRow[];
const total = Number(json.data?.meta?.total ?? page.length);
for (const measurement of page) {
const rawValue = measurement.value;
const value =
valueMode === "bmi"
? bmiDivisor
? rawValue / bmiDivisor
: null
: rawValue;
for (const measurement of page) {
const rawValue = measurement.value;
const value =
valueMode === "bmi"
? bmiDivisor
? rawValue / bmiDivisor
: null
: rawValue;
if (value == null || !Number.isFinite(value)) {
continue;
}
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? { sum: 0, count: 0 };
current.sum += value;
current.count += 1;
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
if (value == null || !Number.isFinite(value)) {
continue;
}
offset += page.length;
if (page.length === 0 || offset >= total || page.length < pageSize) {
break;
}
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? { sum: 0, count: 0 };
current.sum += value;
current.count += 1;
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
}
}
await Promise.all(types.map(fetchAllMeasurementsByType));
await Promise.all(types.map(fetchMeasurementsByType));
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
.map((bucket) => {
+34 -10
View File
@@ -54,25 +54,25 @@ interface ChartDataPoint {
score: number;
ma?: number;
trend?: number;
/** v1.4.16 phase B8 — prior-period overlay value. */
/** v1.4.16 B8 — prior-period overlay value. */
scoreCompare?: number;
}
interface MoodChartProps {
title?: string;
/**
* v1.4.16 phase B5c — compact mode for embedding inside the
* v1.4.16 B5c — compact mode for embedding inside the
* RecommendationCard. Hides the range tabs / toggle row and shrinks
* padding. Tooltip stays intact.
*/
mini?: boolean;
/**
* v1.4.16 phase B5c — pin the chart to a specific rationale data
* v1.4.16 B5c — pin the chart to a specific rationale data
* window regardless of any parent UI state.
*/
windowOverride?: "last7days" | "last30days" | "last90days" | "allTime";
/**
* v1.4.16 phase B8 — when set to "lastMonth" / "lastYear", overlay a
* v1.4.16 B8 — when set to "lastMonth" / "lastYear", overlay a
* dimmed prior-period mood line beneath the current series. Same
* shift mechanic the BP/weight/pulse chart uses; the mood score is
* a single metric so only one comparison line is drawn.
@@ -419,7 +419,7 @@ export function MoodChart({
}, [data, rangePoints, showMA, showTrend, tzFmt]);
/**
* v1.4.16 phase B8 — comparison overlay merged into chartData.
* v1.4.16 B8 — comparison overlay merged into chartData.
*
* Same mechanic as HealthChart: shift the full mood-entries history
* forward by 30 / 365 days, key by the existing `date` formatter, and
@@ -527,8 +527,32 @@ export function MoodChart({
if (!isLoading && !data?.entries?.length) return null;
return (
<Card data-slot={mini ? "chart-mini" : undefined}>
<CardHeader className={mini ? "pb-1" : "pb-2"}>
<Card
data-slot={mini ? "chart-mini" : undefined}
// v1.4.28 R3c-Insights — collapse the Card envelope in mini
// mode (FB-K1). The default `<Card>` paints `py-6 gap-6` —
// ~48 px of vertical chrome that pulled the mood mini chart
// band ~52 px lower than the `<HealthChart mini>` siblings
// in the trends row. The mini override gives mood the same
// `~p-2` shell HealthChart uses so the chart series anchor
// at the same top edge across BP / weight / mood tiles.
//
// D-H5 follow-up — the Card primitive carries `rounded-xl
// border bg-card shadow-sm` by default. HealthChart mini
// paints `rounded-md border bg-card` (no shadow). Without an
// explicit `rounded-md` override the mood tile painted a
// visibly heavier corner radius than BP / weight in the same
// row. Align to the `rounded-md` shell so the three trend
// tiles share one corner radius.
className={
mini ? "gap-1 rounded-md py-2 shadow-none" : undefined
}
>
<CardHeader
className={
mini ? "px-2 pb-1 [&]:gap-0.5" : "pb-2"
}
>
{/* v1.4.19 A2 — mobile-first header: stack title row above
controls row on small viewports so the bucket / comparison
chips never push the range tabs into a 2nd line. ≥sm goes
@@ -553,7 +577,7 @@ export function MoodChart({
)}
</span>
)}
{/* v1.4.16 phase B8 — comparison caption (mood).
{/* v1.4.16 B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{!mini &&
effectiveCompareBaseline !== "none" &&
@@ -617,7 +641,7 @@ export function MoodChart({
)}
</div>
</CardHeader>
<CardContent>
<CardContent className={mini ? "px-2" : undefined}>
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
@@ -888,7 +912,7 @@ export function MoodChart({
connectNulls
/>
)}
{/* v1.4.16 phase B8 — comparison overlay (mood). Same
{/* v1.4.16 B8 — comparison overlay (mood). Same
dimmed dashed treatment the BP/weight/pulse chart
uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior
+6 -43
View File
@@ -87,17 +87,6 @@ interface TrendCardProps {
*/
compareBaseline?: ComparisonBaseline;
compareDelta?: number | null;
/**
* v1.4.22 A2 — optional third sub-value rendered alongside `7d` and
* `30d`. Used by the BD-Zielbereich tile so the all-time aggregate
* stays visible after v1.4.22 A1 re-anchored the headline to the
* 30-day window. Pass the i18n key for the label (defaults to
* `charts.avgAllTimeShort`); other tiles leave both fields undefined
* so the third sub-row never renders.
*/
avgAllTime?: number | null;
avgAllTimeLabelKey?: string;
avgAllTimeColorClass?: string;
}
export function TrendCard({
@@ -117,9 +106,6 @@ export function TrendCard({
trend7Delta = null,
compareBaseline = "none",
compareDelta = null,
avgAllTime,
avgAllTimeLabelKey = "charts.avgAllTimeShort",
avgAllTimeColorClass,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -374,35 +360,12 @@ export function TrendCard({
</span>
)}
</span>
{/* v1.4.22 A2 — optional third sub-value (e.g. all-time aggregate
for BD-Zielbereich after v1.4.22 A1 re-anchored the headline
to last-30 days). Other tiles leave `avgAllTime` undefined so
the third sub-row never renders.
v1.4.27 MB7 / CF-41 — the third sub-value renders inline
alongside `7d` / `30d` on every viewport. The previous
wave split `<sm` into a separate secondary row to keep
tile heights uniform, but in practice the flex-wrap on
the parent already handles the overflow gracefully — the
all-time pair wraps to a second line when the first row
fills up. Dropping the duplicated mobile-secondary block
also dropped CF-67 (the vestigial wrapper). */}
{avgAllTime !== undefined && (
<span
data-slot="trend-card-all-time"
className="max-w-full min-w-0 [overflow-wrap:anywhere]"
>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
)}
{/* v1.4.28 FB-C2 — the all-time third sub-value retired
alongside the `avgAllTime*` props. The BD-Zielbereich
tile was the only consumer; the same number stays
visible on the `/targets` Blutdruck card with full
context. Every dashboard tile now ships the same two
sub-rows. */}
</div>
</TooltipProvider>
</div>
@@ -1,436 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.25 W6 — `<Glp1Tile>` rendering contract.
*
* Suite pins three scenarios:
* 1. No active GLP-1 medication → the tile suppresses itself
* entirely (returns null, no DOM output).
* 2. Single active GLP-1 medication (Mounjaro 7.5 mg, weekly) →
* drug + dose caption, last + next injection lines, weight
* delta caption, vertical injection markers wired to the chart.
* 3. Dose-changed mid-period (Mounjaro 5 mg → 7.5 mg titration) →
* the displayed current-dose follows the latest titration row
* (the snapshot already does this server-side; the tile just
* surfaces it).
*
* Approach: mock the React Query hook + the auth hook so the suite
* can render `<Glp1Tile />` to static markup and assert the visible
* DOM. The chart itself is dynamic-imported so it's a `<div>` shell
* in SSR — we assert the data-slot wires by reading the surrounding
* markup rather than the rendered chart canvas.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { id: "marc", timezone: "Europe/Berlin" },
}),
}));
let queryReturn: { data: unknown; isPending: boolean } = {
data: null,
isPending: false,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryReturn,
}));
// `next/dynamic` returns the lazy-loaded component reference; for
// renderToStaticMarkup the dynamic shell is a sufficient placeholder
// (we never render the chart canvas — only assert the tile wires).
vi.mock("next/dynamic", () => ({
__esModule: true,
default: () => {
const Mock = () => null;
Mock.displayName = "DynamicChart";
return Mock;
},
}));
import { Glp1Tile } from "../glp1-tile";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<Glp1Tile />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
queryReturn = { data: null, isPending: false };
});
describe("<Glp1Tile>", () => {
it("renders nothing when the user has no active GLP-1 medication", () => {
queryReturn = { data: null, isPending: false };
const html = render();
// The route returns `data: null` for users with no active GLP-1.
// The tile must suppress itself entirely so the dashboard layout
// doesn't paint a hollow card.
expect(html).toBe("");
});
it("renders nothing when the medications array is empty", () => {
queryReturn = {
data: { active: false, medications: [] },
isPending: false,
};
const html = render();
expect(html).toBe("");
});
it("renders a skeleton while the fetch is pending", () => {
queryReturn = { data: undefined, isPending: true };
const html = render();
expect(html).toContain("glp1-tile-skeleton");
});
it("renders drug + dose + injection captions for a single active GLP-1", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [
{
value: 2.5,
unit: "mg",
effectiveFrom: "2026-01-01",
note: null,
},
{
value: 5,
unit: "mg",
effectiveFrom: "2026-02-01",
note: null,
},
{
value: 7.5,
unit: "mg",
effectiveFrom: "2026-04-01",
note: null,
},
],
lastInjection: {
date: "2026-05-10",
site: "ABDOMEN_LEFT",
weeksAgo: 0,
},
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [
{ date: "2026-01-01", weight: 92.0 },
{ date: "2026-05-13", weight: 87.8 },
],
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
expect(html).toContain('data-slot="glp1-tile"');
expect(html).toContain('data-medication-id="med-1"');
// Drug-and-dose caption.
expect(html).toContain("Mounjaro 7.5mg");
// The "−4.2 kg since start" delta caption with the loss tone.
expect(html).toContain('data-tone="loss"');
expect(html).toContain("−4.2 kg since start");
// Last + next injection slots wired into the DOM.
expect(html).toContain('data-slot="glp1-tile-last"');
expect(html).toContain('data-slot="glp1-tile-next"');
expect(html).toContain('data-slot="glp1-tile-chart"');
});
it("reflects the LATEST titration step when the dose changed mid-period", () => {
// Mounjaro titration: 2.5 → 5 → 7.5 mg. The most recent dose-change
// row dictates the tile's headline dose. The route's snapshot
// already does this server-side; this test pins the tile's
// dependence on `currentDose.value` rather than e.g. the first
// entry in doseHistory.
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 10,
unit: "mg",
since: "2026-05-01",
weeksOnDose: 1,
},
doseHistory: [
{
value: 2.5,
unit: "mg",
effectiveFrom: "2026-01-01",
note: null,
},
{
value: 5,
unit: "mg",
effectiveFrom: "2026-02-01",
note: null,
},
{
value: 7.5,
unit: "mg",
effectiveFrom: "2026-03-01",
note: null,
},
{
value: 10,
unit: "mg",
effectiveFrom: "2026-05-01",
note: "tolerated 10 mg step-up",
},
],
lastInjection: null,
nextInjection: null,
startWeight: 95.0,
currentWeight: 88.0,
weightDeltaKg: -7.0,
weightSeries: [
{ date: "2026-01-01", weight: 95.0 },
{ date: "2026-05-13", weight: 88.0 },
],
injectionDates: [],
},
],
},
isPending: false,
};
const html = render();
// Latest titration step (10 mg) wins — not the first entry (2.5).
expect(html).toContain("Mounjaro 10mg");
expect(html).not.toContain("Mounjaro 2.5mg");
// weightDelta = -7.0 → loss tone with the "since start" suffix.
expect(html).toContain('data-tone="loss"');
});
it("falls back to the medication name alone when no current dose is set", () => {
// Some legacy meds have no titration history; the tile should
// still render the rest of the info without crashing on the
// missing dose row.
queryReturn = {
data: {
active: true,
medications: [
{
name: "Ozempic",
genericName: "semaglutide",
medicationId: "med-1",
currentDose: null,
doseHistory: [],
lastInjection: null,
nextInjection: null,
startWeight: null,
currentWeight: null,
weightDeltaKg: null,
weightSeries: [],
injectionDates: [],
},
],
},
isPending: false,
};
const html = render();
expect(html).toContain("Ozempic");
// No "mg" suffix because we have no dose value.
expect(html).not.toContain("Ozempic 0mg");
// No weight delta caption when both sides are null.
expect(html).not.toContain('data-slot="glp1-tile-delta"');
// v1.4.27 B1 — the tab strip + range strip always render; the
// weight pane shows the muted "no data yet" hint when the series
// is empty rather than suppressing the whole chart row.
expect(html).toContain('data-slot="glp1-tile-chart"');
expect(html).toContain('data-slot="glp1-tile-tab-level"');
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
});
it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
// Tab strip + range strip are present on a populated tile.
expect(html).toContain('data-slot="glp1-tile-tabs"');
expect(html).toContain('data-slot="glp1-tile-tab-level"');
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
expect(html).toContain('data-slot="glp1-tile-range-strip"');
// Drug-Level tab is the default selection.
expect(html).toMatch(
/data-slot="glp1-tile-tab-level"[^>]*data-active="true"/,
);
expect(html).toMatch(
/data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/,
);
// Range strip exposes 7d / 30d / 90d / All buttons.
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/);
// 30d preset is the default.
expect(html).toMatch(
/data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/,
);
});
it("v1.4.27 B1 — promotes the schedule to a pill row and drops the green seam", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: ["2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
// Schedule wrapper is the new pill row, not the old <dl>.
expect(html).toContain('data-slot="glp1-tile-schedule"');
expect(html).not.toContain("<dl");
// Pills still carry the per-pill slot anchors the existing tests use.
expect(html).toContain('data-slot="glp1-tile-last"');
expect(html).toContain('data-slot="glp1-tile-next"');
// The green left-seam classes drop from the outer wrapper.
expect(html).not.toContain("border-l-dracula-green/60");
expect(html).not.toContain("border-l-2");
});
it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => {
// Some legacy meds don't have a Medication.id surfaced through the
// dashboard route (the route returns medicationId: null). The
// drug-level pane needs a stable medication id to mount the
// chart, so we render a muted hint instead of the chart.
queryReturn = {
data: {
active: true,
medications: [
{
name: "Ozempic",
genericName: "semaglutide",
medicationId: null,
currentDose: {
value: 0.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: null,
nextInjection: null,
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: [],
},
],
},
isPending: false,
};
const html = render();
// Drug-level tab stays selected by default; pane renders the
// unavailable hint because medicationId is null.
expect(html).toContain('data-slot="glp1-tile-level-unavailable"');
});
it("renders German copy under the de locale", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: [],
},
],
},
isPending: false,
};
const html = render("de");
expect(html).toContain("GLP-1-Therapie");
expect(html).toContain("Letzte Injektion");
expect(html).toContain("Nächste Injektion");
expect(html).toContain("seit Beginn");
});
});
-497
View File
@@ -1,497 +0,0 @@
"use client";
/**
* v1.4.25 W6 — GLP-1 status tile.
*
* Renders only when the user has at least one active GLP-1 medication
* (`Medication.treatmentClass = "GLP1"`). The tile shows:
* - drug name + current dose ("Mounjaro 7.5mg")
* - last injection date + weekday
* - next injection date + weekday + countdown
* - weight delta since starting the medication ("−4.2 kg seit Beginn")
* - a compact weight chart with vertical injection-day markers so the
* user sees the dose-response visually
*
* Self-hides when:
* - no GLP-1 medication is active (route returns `data: null`)
* - the fetch is still pending (renders a skinny skeleton so the
* dashboard layout doesn't jump)
* - the fetch errored (silently — the tile is enrichment, not a
* hard requirement, and the rest of the dashboard works without
* it)
*
* The chart lives INSIDE the tile per Marc's directive 2026-05-14:
* "NO chart on the Medications page itself. The chart goes on the
* Dashboard tile and into the Insights /medikamente sub-page".
*/
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { CHART_RANGE_PRESETS } from "@/lib/charts/constants";
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
const DrugLevelChart = dynamic(
() =>
import("@/components/medications/DrugLevelChart").then((mod) => ({
default: mod.DrugLevelChart,
})),
{ ssr: false },
);
/**
* Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's
* window length in hours. `points: 0` (the "All" preset) maps to 365
* days — past that point the unit-less curve flattens to invisibility,
* so a year of history is the practical ceiling.
*/
const HOURS_PER_DAY = 24;
function rangePointsToHours(points: number): number {
if (points === 0) return 365 * HOURS_PER_DAY;
return points * HOURS_PER_DAY;
}
type Glp1ChartTab = "level" | "weight";
interface DoseHistoryEntry {
value: number;
unit: string;
effectiveFrom: string;
note: string | null;
}
interface CurrentDose {
value: number;
unit: string;
since: string;
weeksOnDose: number;
}
interface LastInjection {
date: string;
site: string | null;
weeksAgo: number;
}
interface NextInjection {
date: string;
daysAway: number;
}
interface Glp1MedicationPayload {
name: string;
genericName: string;
medicationId: string | null;
currentDose: CurrentDose | null;
doseHistory: DoseHistoryEntry[];
lastInjection: LastInjection | null;
nextInjection: NextInjection | null;
startWeight: number | null;
currentWeight: number | null;
weightDeltaKg: number | null;
weightSeries: Array<{ date: string; weight: number }>;
injectionDates: string[];
}
interface Glp1Payload {
active: boolean;
medications: Glp1MedicationPayload[];
}
interface Glp1ApiResponse {
data: Glp1Payload | null;
}
/**
* Compose a single-line drug+dose caption ("Mounjaro 7.5mg"). The
* route exposes the parsed dose value + unit on `currentDose` (from
* `MedicationDoseChange` — the source of record). When the user
* hasn't logged a titration history we fall back to the medication
* name alone — better blank than wrong.
*/
function formatDrugAndDose(med: Glp1MedicationPayload): string {
if (!med.currentDose) return med.name;
const dose = `${med.currentDose.value}${med.currentDose.unit}`;
return `${med.name} ${dose}`;
}
/**
* Format a `YYYY-MM-DD` date string as "Mon 12 May" / "Mo, 12 Mai"
* via the user's active locale formatters. The weekday is part of
* the caption Marc asked for ("Last injection: <date> (<weekday>)").
*/
function useDateWithWeekday(): (iso: string) => string {
const fmt = useFormatters();
return useMemo(() => {
return (iso: string) => {
const d = new Date(`${iso}T12:00:00Z`);
return fmt.dateWithWeekday(d);
};
}, [fmt]);
}
interface DeltaDisplay {
text: string;
icon: typeof ArrowDown;
tone: "loss" | "gain" | "flat";
}
/**
* The weight-delta caption ("−4.2 kg seit Beginn") is the headline
* outcome of GLP-1 therapy. We carve it out as a small helper so the
* sign formatting + icon choice stays in one place:
* - delta < 0 → loss (green arrow-down). The clinically desired
* direction for weight-management indications.
* - delta > 0 → gain (orange arrow-up). Not necessarily a bad
* outcome (e.g. recovering from underweight), so we use the
* muted warning palette instead of red.
* - delta ≈ 0 → flat (muted minus). Within ±0.1 kg counts as flat
* so the caption doesn't flicker on rounding noise.
*/
function useDeltaDisplay(): (deltaKg: number, suffix: string) => DeltaDisplay {
const fmt = useFormatters();
return useMemo(() => {
return (deltaKg: number, suffix: string) => {
const rounded = Math.round(deltaKg * 10) / 10;
if (Math.abs(rounded) < 0.1) {
return {
text: `${fmt.number(0, 1)} kg ${suffix}`,
icon: Minus,
tone: "flat" as const,
};
}
const sign = rounded > 0 ? "+" : "−";
const display = `${sign}${fmt.number(Math.abs(rounded), 1)} kg ${suffix}`;
return {
text: display,
icon: rounded < 0 ? ArrowDown : ArrowUp,
tone: rounded < 0 ? ("loss" as const) : ("gain" as const),
};
};
}, [fmt]);
}
export function Glp1Tile() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const dateWithWeekday = useDateWithWeekday();
const deltaDisplay = useDeltaDisplay();
// v1.4.27 B1 — the chart pane carries two views (drug-level / weight)
// and a 7d / 30d / 90d / All range strip above. Drug-level is the
// default because it's the more informative pane the maintainer
// asked for; the weight pane stays one tap away. Both states live
// on the tile so a parent re-render doesn't reset the user's pick.
const [activeTab, setActiveTab] = useState<Glp1ChartTab>("level");
const [rangePoints, setRangePoints] = useState<number>(30);
const { data, isPending } = useQuery({
queryKey: queryKeys.dashboardGlp1(),
enabled: isAuthenticated,
queryFn: async (): Promise<Glp1Payload | null> => {
const res = await fetch("/api/dashboard/glp1");
if (!res.ok) throw new Error("glp1 fetch failed");
const body = (await res.json()) as Glp1ApiResponse;
return body.data;
},
// Conservative — the tile reads cheap server-side aggregates so
// an aggressive stale-time would mask a fresh injection logged
// a few minutes earlier. 60 s mirrors the rest of the dashboard
// query lifecycle.
staleTime: 60_000,
});
// Self-hide everywhere except when we have an active payload.
if (isPending && isAuthenticated) {
return (
<div
data-slot="glp1-tile-skeleton"
className="bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm"
>
<div className="bg-muted/40 mb-3 h-4 w-24 animate-pulse rounded" />
<div className="bg-muted/40 h-32 w-full animate-pulse rounded" />
</div>
);
}
if (!data || data.medications.length === 0) {
return null;
}
// The brief mentions "renders ONLY when the user has at least one
// active medication with category=GLP1". The route already gates on
// `treatmentClass === "GLP1"` and `active === true`, so `data` here
// is guaranteed to have at least one med. We render the FIRST med —
// multiple concurrent GLP-1 prescriptions are clinically unusual; if
// a future user has two, the Insights /medikamente sub-page will be
// the place to see both side-by-side.
const med = data.medications[0];
const drugLine = formatDrugAndDose(med);
return (
<div
data-slot="glp1-tile"
data-medication-id={med.medicationId ?? ""}
className={cn(
"bg-card/65 relative overflow-hidden rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
// v1.4.27 B1 — the green left-seam drops. The Syringe-icon in
// the title row already carries the "active therapy" signal;
// the seam was decoration without a semantic tie to the
// schedule dates next to it. The schedule pill row below now
// groups the two dates visually so the user reads them as one
// cohesive unit.
)}
>
<div className="flex items-center gap-2 pb-3">
<Syringe className="text-dracula-green h-4 w-4" aria-hidden="true" />
<h2
data-slot="glp1-tile-title"
className="text-foreground text-sm font-semibold tracking-tight"
>
{t("dashboard.glp1.title")}
</h2>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 pb-3">
<span
data-slot="glp1-tile-drug"
className="text-foreground text-lg font-semibold tabular-nums"
>
{drugLine}
</span>
{med.weightDeltaKg !== null && (
<DeltaCaption
display={deltaDisplay(
med.weightDeltaKg,
t("dashboard.glp1.weightDelta"),
)}
/>
)}
</div>
{/* v1.4.27 B1 — schedule pill row promotes the two injection
dates to a header band. Pills carry a soft dracula-green tint
so the two dates read as one cohesive "therapy schedule"
unit; the old <dl> grid + arbitrary green seam are gone. */}
{(med.lastInjection || med.nextInjection) && (
<div
data-slot="glp1-tile-schedule"
className="flex flex-wrap items-center gap-1.5 pb-3 text-xs"
>
{med.lastInjection && (
<span
data-slot="glp1-tile-last"
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
>
<Calendar
className="text-dracula-green/80 h-3 w-3 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground font-medium">
{t("dashboard.glp1.lastInjection")}:
</span>
<span>{dateWithWeekday(med.lastInjection.date)}</span>
</span>
)}
{med.nextInjection && (
<span
data-slot="glp1-tile-next"
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
>
<Syringe
className="text-dracula-green/80 h-3 w-3 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground font-medium">
{t("dashboard.glp1.nextInjection")}:
</span>
<span>{dateWithWeekday(med.nextInjection.date)}</span>
<span className="text-muted-foreground">
·{" "}
{t("dashboard.glp1.inDays", {
count: med.nextInjection.daysAway,
})}
</span>
</span>
)}
</div>
)}
{/* v1.4.27 B1 — chart pane carrying a two-tab segmented control
(Drug-Level default / Weight) plus a 7d / 30d / 90d / All
range strip above. Drug-Level reads the unit-less curve from
`<DrugLevelChart compact …>` (Research Mode gating is owned
by the chart itself); Weight retains the v1.4.25 W6 mini
chart with vertical injection markers. */}
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="glp1-tile-tabs"
role="tablist"
aria-label={t("dashboard.glp1.tabsAria")}
className="bg-muted/40 inline-flex rounded-md p-0.5"
>
<TabButton
active={activeTab === "level"}
onClick={() => setActiveTab("level")}
label={t("dashboard.glp1.tabLevel")}
slot="glp1-tile-tab-level"
/>
<TabButton
active={activeTab === "weight"}
onClick={() => setActiveTab("weight")}
label={t("dashboard.glp1.tabWeight")}
slot="glp1-tile-tab-weight"
/>
</div>
<div
data-slot="glp1-tile-range-strip"
role="radiogroup"
aria-label={t("dashboard.glp1.rangeStripLabel")}
className="inline-flex items-center gap-1 text-[11px]"
>
{CHART_RANGE_PRESETS.map((preset) => (
<button
key={preset.points}
type="button"
role="radio"
aria-checked={rangePoints === preset.points}
data-slot="glp1-tile-range-button"
data-points={preset.points}
data-active={rangePoints === preset.points ? "true" : "false"}
title={t(preset.titleKey)}
onClick={() => setRangePoints(preset.points)}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded px-3 font-medium tabular-nums",
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
rangePoints === preset.points
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground",
)}
>
{t(preset.labelKey)}
</button>
))}
</div>
</div>
{activeTab === "level" && med.medicationId ? (
<DrugLevelChart
compact
medication={{
id: med.medicationId,
name: med.name,
dose: med.currentDose
? `${med.currentDose.value}${med.currentDose.unit}`
: "",
}}
windowHoursBefore={rangePointsToHours(rangePoints)}
/>
) : activeTab === "level" ? (
<p
data-slot="glp1-tile-level-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.levelUnavailable")}
</p>
) : med.weightSeries.length > 0 ? (
<HealthChart
mini
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["#bd93f9"]}
unit="kg"
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
userTimezone={user?.timezone}
/>
) : (
<p
data-slot="glp1-tile-weight-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.weightUnavailable")}
</p>
)}
</div>
</div>
);
}
/**
* v1.4.27 B1 — small segmented-control button used by the tile's
* tab strip. Carved into its own component so the active/inactive
* styling lives in one place and the test can pin the data-slot
* markers per tab.
*/
function TabButton({
active,
onClick,
label,
slot,
}: {
active: boolean;
onClick: () => void;
label: string;
slot: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
data-slot={slot}
data-active={active ? "true" : "false"}
onClick={onClick}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded px-3 text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
/**
* Carve the delta caption pill into its own component so the
* loss/gain/flat tone selection is colocated with the rendering. The
* pill itself is the small green/orange/muted chip that sits next to
* the drug name on the second tile row.
*/
function DeltaCaption({ display }: { display: DeltaDisplay }) {
const Icon = display.icon;
const toneClass =
display.tone === "loss"
? "bg-dracula-green/15 text-dracula-green"
: display.tone === "gain"
? "bg-dracula-orange/15 text-dracula-orange"
: "bg-muted/50 text-muted-foreground";
return (
<span
data-slot="glp1-tile-delta"
data-tone={display.tone}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
toneClass,
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
{display.text}
</span>
);
}
@@ -127,6 +127,30 @@ describe("<HealthScoreCard>", () => {
expect(html).toContain("-7 vs last week");
});
it("threads aria-describedby from the delta text to the explainer body", () => {
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the
// explainer trigger's aria-controls reads the same id. Both
// attributes share one `useId()` thread so SR can connect
// "−3 vs last week" to the three-sentence read.
const html = ssr(
<HealthScoreCard
score={64}
band="yellow"
components={baseComponents}
delta={-3}
/>,
);
// The aria-describedby attribute lands on the delta span.
const deltaSpan = html.match(
/<span\s+aria-describedby="([^"]+)"[^>]*>[^<]*vs last week[^<]*<\/span>/,
);
expect(deltaSpan).not.toBeNull();
const threadedId = deltaSpan?.[1] ?? "";
expect(threadedId).not.toBe("");
// The same id sits on the trigger button's aria-controls.
expect(html).toContain(`aria-controls="${threadedId}"`);
});
it("renders four component rows with their values", () => {
const html = ssr(
<HealthScoreCard
@@ -260,6 +284,50 @@ describe("<HealthScoreCard>", () => {
expect(progressMatch?.[1]).toBe("100%");
});
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
it("opts the wrapper into the parent's flex-stretch row via h-full + flex-col", () => {
// The hero strip's row now uses `items-stretch`; the card has to
// declare `h-full flex flex-col` so the stretched shell actually
// affects its rendered height. Pin both classes so a refactor
// can't silently drop one.
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
const cardOpen = html.match(
/<div[^>]*data-slot="health-score-card"[^>]*>/,
);
expect(cardOpen).not.toBeNull();
expect(cardOpen?.[0]).toContain("h-full");
expect(cardOpen?.[0]).toContain("flex");
expect(cardOpen?.[0]).toContain("flex-col");
});
it("pins the disclaimer to the bottom of the stretched card via mt-auto", () => {
// Without `mt-auto` the disclaimer rides directly under the
// provenance accordion and leaves the recovered space below the
// disclaimer instead of between the body and the footer. The
// FB-H2 contract is "score number on top, disclaimer on bottom,
// recovered space in the middle".
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
const disclaimerOpen = html.match(
/<p[^>]*data-slot="health-score-card-disclaimer"[^>]*>/,
);
expect(disclaimerOpen).not.toBeNull();
expect(disclaimerOpen?.[0]).toContain("mt-auto");
});
it("exposes the progressbar role + ARIA values for screen readers", () => {
const html = ssr(
<HealthScoreCard
@@ -0,0 +1,201 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
/**
* v1.4.28 R3c-Insights — FB-I1 — the "?" affordance next to the delta
* line opens a 3-sentence read of which components contributed, what
* the comparison window is, and one concrete next step.
*
* Tests run through SSR so they don't depend on a browser surface.
* The popover / sheet body mounts on click on the live page; the
* SSR snapshot captures the trigger button (always present) plus a
* locale-grounded check that the body copy actually exists in the
* translation payload — the load-bearing constraint is "every
* locale ships the 3-sentence read", not the popover open animation.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* Helper that paints the explainer body via the same `t()` call the
* popover uses. If a future refactor drops a locale key the test
* fails at the `t()` call site rather than silently rendering the
* fallback chevron.
*/
function BodyProbe() {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-trigger">
{t("insights.healthScore.deltaExplainer.trigger")}
</span>
<span data-slot="probe-title">
{t("insights.healthScore.deltaExplainer.title")}
</span>
<span data-slot="probe-body">
{t("insights.healthScore.deltaExplainer.body")}
</span>
</div>
);
}
describe("<HealthScoreDeltaExplainer>", () => {
it("renders the icon-only trigger button with an accessible label (EN)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 12 px but the click surface inflates to 44 px
// via `min-h-11 min-w-11` on the trigger; the parent row keeps
// its 16 px stride because `-my-3 -mx-2` swallows the extra
// reach. Pinning so a future refactor can't drop back to the
// visual-chip-as-hit-target shape that failed WCAG 2.5.5.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads aria-expanded + aria-controls onto the trigger button", () => {
// SR users get a clear "this opens a disclosure" cue via
// aria-expanded; the aria-controls value is the same id the
// body paints so the SR can jump to the explanation after
// the user activates the trigger.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain("aria-expanded");
expect(trigger?.[0]).toContain("aria-controls");
});
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
// Parent owns the id and paints aria-describedby on the delta
// span; the explainer re-uses the same id for its
// aria-controls thread. The shared id is the seam SR uses to
// jump from the digit to the explanation.
const html = ssr(
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain('aria-controls="parent-id-x"');
});
it("does not wrap the trigger in a clickable <span> (single interactive element)", () => {
// The earlier mobile path wrapped the button in a
// `<span onClick onKeyDown>` which created two interactive
// elements in the a11y tree and intercepted clicks on the 2 px
// gap around the button. The button now owns the open toggle
// directly. Pinning the negative so a future refactor can't
// reintroduce the wrapper.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toMatch(
/<span[^>]*onclick/i,
);
});
it("renders the trigger in German with the localised aria-label", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain(
'aria-label="Was bedeutet diese Veränderung?"',
);
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default contract: the trigger button is the only
// surface the SSR snapshot owns; the body lives in a portal that
// mounts after the user taps. Pinning the negative side keeps a
// future "open on render" refactor from leaking long-form copy
// into the static markup.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-body"',
);
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-title"',
);
});
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BodyProbe />
</I18nProvider>,
);
const body = html.match(
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
);
expect(body).not.toBeNull();
const text = body?.[1] ?? "";
// Components — window — action triad pinned.
expect(text).toContain("BP, weight, mood and meds");
expect(text).toContain("same time last week");
expect(text).toContain("Log a fresh reading");
// 3-sentence ceiling. Soft check: at most 4 sentence-ending
// punctuation marks (covering the Oxford comma + 3 periods).
const periods = (text.match(/\./g) ?? []).length;
expect(periods).toBeLessThanOrEqual(4);
});
it("ships the DE body copy with the same structure (3-sentence translation)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<BodyProbe />
</I18nProvider>,
);
const body = html.match(
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
);
expect(body).not.toBeNull();
const text = body?.[1] ?? "";
expect(text).toContain("Blutdruck");
expect(text).toContain("Stimmung");
expect(text).toContain("Einnahmetreue");
expect(text).toContain("vor einer Woche");
});
it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => {
// Pinning every locale stops a future feature branch from
// shipping an English fallback for the new explainer keys.
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BodyProbe />
</I18nProvider>,
);
// The probe re-uses the same `t()` payload the popover does,
// so any missing key surfaces as the literal key path which
// would never contain a space.
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.trigger",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.title",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.body",
);
}
});
});
@@ -59,9 +59,9 @@ describe("<HeroStrip>", () => {
it("appends the user name to the greeting when supplied", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} userName="Marc" />,
<HeroStrip briefing={null} now={morningLocal} userName="Alex" />,
);
expect(html).toContain("Good morning, Marc");
expect(html).toContain("Good morning, Alex");
});
it("does NOT append a comma when userName is missing", () => {
@@ -112,10 +112,12 @@ describe("<HeroStrip>", () => {
});
// ── Action row ─────────────────────────────────────────────────────
it("renders the weekly-report action button as disabled with a 'Coming soon' title", () => {
// v1.4.28 retired the weekly-report button — Coach is now the only
// hero-row action. The weekly-report path is gone from the codebase.
it("does NOT render a weekly-report action button", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).toMatch(
/data-slot="insights-hero-strip-action-weekly-report"[^>]*disabled[^>]*title="Coming soon"/,
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-weekly-report"',
);
});
@@ -190,91 +192,14 @@ describe("<HeroStrip>", () => {
});
// ── B4 — Weekly-report banner card ─────────────────────────────────
it("does not render the weekly-report banner when weeklyReportReady is omitted", () => {
// v1.4.28 retired the weekly-report path. The banner, the report
// route, the schema slot and the i18n keys are gone; the hero
// strip never paints the banner under any prop combination now.
it("does not render the weekly-report banner under any prop combination", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"');
});
it("renders the weekly-report banner when weeklyReportReady is supplied", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner"/);
expect(html).toContain("Your 2026-W19 report is ready");
});
it("renders Read / Share / Export PDF actions on the banner", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-read"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-share"/);
expect(html).toMatch(
/data-slot="insights-hero-strip-weekly-banner-export"/,
);
expect(html).toContain("Read");
expect(html).toContain("Share");
expect(html).toContain("Export PDF");
});
it("read action links to the report URL; export action appends print=1", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
);
// Read link points to the bare report URL.
const readLink = html.match(
/<a[^>]*data-slot="insights-hero-strip-weekly-banner-read"[^>]*>/,
);
expect(readLink).not.toBeNull();
expect(readLink?.[0]).toContain('href="/insights/report/2026-W19"');
// Export link adds ?print=1.
const exportLink = html.match(
/<a[^>]*data-slot="insights-hero-strip-weekly-banner-export"[^>]*>/,
);
expect(exportLink).not.toBeNull();
expect(exportLink?.[0]).toContain(
'href="/insights/report/2026-W19?print=1"',
);
});
it("renders the German banner copy", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
"de",
);
expect(html).toContain("Dein 2026-W19-Bericht ist bereit");
expect(html).toContain("Lesen");
expect(html).toContain("Als PDF");
});
// ── B5 — Health Score panel ────────────────────────────────────────
it("does NOT render the Health Score panel when healthScore is omitted", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
@@ -354,4 +279,42 @@ describe("<HeroStrip>", () => {
// is present so the panel sits beside the title block on desktop.
expect(html).toContain("lg:flex-row");
});
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
it("stretches the row's cross-axis when the HealthScore card mounts (md+/lg+)", () => {
// Per Inv-4 the right column painted 75-110 px shorter than the
// left column on desktop. Switching the parent flex row from
// `items-start` to `items-stretch` gives the card a stretched
// shell to grow into. The class is load-bearing for FB-H1/H2 —
// if a future refactor reverts to `items-start` the height gap
// returns.
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: null,
}}
/>,
);
expect(html).toContain("md:items-stretch");
expect(html).toContain("lg:items-stretch");
expect(html).not.toContain("md:items-start");
});
it("does NOT stretch the row when healthScore is omitted", () => {
// Pin the negative case so a future refactor can't blanket-apply
// the stretch contract on the no-score layout (no right column
// means nothing to stretch toward).
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain("md:items-stretch");
});
});
@@ -1,323 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// HealthChart pulls in TanStack Query + recharts. We don't care about its
// internal rendering — we only need to know *that it was rendered* and
// with which `types[]` (which is the chart's metric handle, since the
// real component takes `types: string[]` not `metric: string`).
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
<div data-testid="health-chart" data-types={types.join(",")}>
chart:{title}
</div>
),
}));
// Stub TanStack Query — InsightAdvisorCard itself doesn't query, but
// nested clients (and the HealthChart mock above replaces the real one)
// pull from this. Safe no-op.
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
return {
insightType: "general",
summary: "All good",
classification: "gut",
classificationLabel: "Good",
findings: [
{
label: "Heart rate stable",
value: "72 bpm",
assessment: "positive",
},
],
correlations: [],
primaryRecommendation: "Keep going",
recommendations: [],
dataQuality: {
coverage: "good",
gaps: [],
confidence: "hoch",
},
disclaimer: "Not a substitute for medical advice.",
...overrides,
};
}
function render(insight: InsightResult) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Test card" insight={insight} />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<InsightAdvisorCard> — inline chart tokens", () => {
it("renders an inline HealthChart for an allowlisted token in summary", () => {
const html = render(
buildInsight({
summary: "BP looking great metric:BLOOD_PRESSURE_SYS today",
}),
);
// Chart is rendered with the token's metric as the type.
expect(html).toContain('data-testid="health-chart"');
expect(html).toContain('data-types="BLOOD_PRESSURE_SYS"');
// Token literal is stripped from the visible prose.
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
// Surrounding prose survives unchanged.
expect(html).toContain("BP looking great");
expect(html).toContain("today");
});
it("drops hallucinated tokens silently and still strips the literal", () => {
const html = render(
buildInsight({
summary: "metric:NUKE try this",
}),
);
// No chart rendered.
expect(html).not.toContain('data-testid="health-chart"');
// Token literal is gone from the DOM.
expect(html).not.toContain("metric:NUKE");
// Visible text is the surrounding prose only.
expect(html).toContain("try this");
});
it("treats injection attempts as plain text — no script, no markup", () => {
const html = render(
buildInsight({
summary: "metric:WEIGHT' onclick='alert(1)'",
}),
);
// The chart still renders for the safe metric prefix.
expect(html).toContain('data-testid="health-chart"');
expect(html).toContain('data-types="WEIGHT"');
// The trailing junk is preserved as escaped text — no script tag,
// no live `onclick` attribute survives the React text path.
expect(html).not.toContain("<script");
expect(html).not.toMatch(/<[^>]*\sonclick\s*=/i);
// The escaped remnant still appears in the prose so the user can see
// what the model emitted (alert(1) is now inert HTML-encoded text).
expect(html).toContain("alert(1)");
});
it("renders an inline chart attached to a hero finding token", () => {
const html = render(
buildInsight({
findings: [
{
label: "Pulse trending down metric:PULSE",
value: "68 bpm",
assessment: "positive",
},
],
}),
);
expect(html).toContain('data-types="PULSE"');
expect(html).not.toContain("metric:PULSE");
expect(html).toContain("Pulse trending down");
});
it("renders inline charts for secondary findings without leaking tokens", () => {
const html = render(
buildInsight({
findings: [
{
label: "Hero finding",
value: "120/80",
assessment: "neutral",
},
{
label: "Weight stable metric:WEIGHT",
value: "82 kg",
assessment: "positive",
},
],
}),
);
expect(html).toContain('data-types="WEIGHT"');
expect(html).not.toContain("metric:WEIGHT");
expect(html).toContain("Weight stable");
});
});
describe("<InsightAdvisorCard> — summary typography polish (B1b)", () => {
it("paints the summary slot with a constrained max-width for readability", () => {
const html = render(
buildInsight({
summary: "Your BP averaged 132/84 over the last 30 days.",
}),
);
expect(html).toMatch(/data-slot="insight-summary"/);
// The summary slot carries a Tailwind max-w-* class so prose
// doesn't stretch full-bleed on a wide viewport.
expect(html).toMatch(/max-w-(prose|2xl|3xl)/);
});
it("uses the polished prose typography (text-base, leading-relaxed)", () => {
const html = render(
buildInsight({
summary: "All metrics within target.",
}),
);
// A larger font + relaxed line-height for the page-anchored
// summary; the per-card content uses text-sm.
expect(html).toMatch(
/data-slot="insight-summary"[^>]*class="[^"]*text-base/,
);
expect(html).toMatch(
/data-slot="insight-summary"[^>]*class="[^"]*leading-relaxed/,
);
});
it("renders summary inline charts as mini sparklines (data-mini=true)", () => {
const html = render(
buildInsight({
summary: "BP averaging 132/84 metric:BLOOD_PRESSURE_SYS today",
}),
);
// The summary's inline-chart wrapper must carry `data-mini="true"`
// so the chart shrinks to sparkline form. Per-finding charts use
// the default detail mode.
expect(html).toMatch(/data-slot="insight-summary"[\s\S]*?data-mini="true"/);
});
});
describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)", () => {
it("loading state shows a skeleton that mirrors the final layout", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Loading test" insight={null} loading />
</I18nProvider>,
);
// A discoverable skeleton slot replaces the spinner-only loading state.
expect(html).toMatch(/data-slot="insight-skeleton"/);
// 3 placeholder rec rows match the final card grid (so the page
// doesn't visually jump when content lands).
expect(html).toMatch(/data-slot="insight-skeleton-card"/);
});
it("empty state shows a friendly illustration + CTA", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Empty test"
insight={null}
onRegenerate={() => {}}
/>
</I18nProvider>,
);
expect(html).toMatch(/data-slot="insight-empty-state"/);
// The Start-Analysis button stays — that's the CTA.
expect(html).toContain("Start analysis");
});
it("error state surfaces a retry button when onRegenerate is provided", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Error test"
insight={null}
error="Something went wrong"
onRegenerate={() => {}}
/>
</I18nProvider>,
);
expect(html).toMatch(/data-slot="insight-error-state"/);
expect(html).toContain("Something went wrong");
expect(html).toMatch(/data-slot="insight-retry-button"/);
});
it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => {
// Reproduces the maintainer's production /insights crash on 2026-05-10:
//
// TypeError: Cannot read properties of undefined (reading 'replace')
// at stripChartTokens(insight.summary)
//
// The cached blob in the DB predates v1.4.16's strict insight schema
// (no `summary`, no `recommendations[]`, no `findings[]`, no
// `dataQuality`, no `disclaimer` — it carries the old `changed`,
// `stable`, `drivers`, `nextSteps`, `confidence`, `limitations`
// shape). The route's `safeParse(parsed)` fails and falls through
// to `insights = parsed`, so the legacy blob reaches the card with
// `summary === undefined`. The card must surface a regenerate CTA
// and skip rendering the prose, NOT crash.
const legacy = {
changed: "Long-term improvement on weight and BP.",
stable: "Pulse remains stable.",
drivers: "Weight reduction may have contributed.",
nextSteps: "Keep going.",
confidence: "hoch",
limitations: "Correlations don't imply causation.",
} as unknown as InsightResult;
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test card"
insight={legacy}
legacyPayload
onRegenerate={() => {}}
/>
</I18nProvider>,
);
// No crash means the test got here. Surface the regenerate CTA so
// the user has a one-click escape from the legacy state.
expect(html).toContain('data-slot="insight-legacy-payload-cta"');
// F-06 (v1.4.19): the CTA copy says "regenerate", so the button
// label must say "Regenerate" too — not "Start analysis".
expect(html).toContain("Regenerate");
});
it("error state without onRegenerate hides the retry button", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Error test"
insight={null}
error="No retry available"
/>
</I18nProvider>,
);
expect(html).toContain("No retry available");
expect(html).not.toContain('data-slot="insight-retry-button"');
});
});
@@ -1,164 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// HealthChart pulls in TanStack Query + recharts. Stub it for the
// same reasons the sibling card test does.
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
<div data-testid="health-chart" data-types={types.join(",")}>
chart:{title}
</div>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart" />,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
const knownRef = MEDICAL_REFERENCES[0];
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
return {
insightType: "general",
summary: "All good",
classification: "gut",
classificationLabel: "Good",
findings: [
{
label: "Heart rate stable",
value: "72 bpm",
assessment: "positive",
},
],
correlations: [],
primaryRecommendation: "Keep going",
recommendations: [],
dataQuality: {
coverage: "good",
gaps: [],
confidence: "hoch",
},
disclaimer: "Not a substitute for medical advice.",
...overrides,
};
}
function render(insight: InsightResult, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<InsightAdvisorCard title="Test card" insight={insight} />
</I18nProvider>,
);
}
describe("<InsightAdvisorCard> — recommendation citation footnote (B5a)", () => {
it("renders no footnote for legacy string recommendations", () => {
const html = render(
buildInsight({
recommendations: ["Continue daily logging."],
}),
);
expect(html).toContain("Continue daily logging.");
expect(html).not.toContain('data-slot="insight-recommendation-source"');
expect(html).not.toContain("Source:");
});
it("renders no footnote for structured rec WITHOUT a referenceId", () => {
const html = render(
buildInsight({
recommendations: [{ text: "Keep an eye on your sleep." }],
}),
);
expect(html).toContain("Keep an eye on your sleep.");
expect(html).not.toContain('data-slot="insight-recommendation-source"');
});
it("renders the footnote with EN title + URL when referenceId resolves", () => {
const html = render(
buildInsight({
recommendations: [
{
text: "Aim for a target below 140/90.",
referenceId: knownRef.id,
},
],
}),
"en",
);
expect(html).toContain('data-slot="insight-recommendation-source"');
expect(html).toContain(`data-reference-id="${knownRef.id}"`);
expect(html).toContain(knownRef.title);
expect(html).toContain(`${knownRef.publishedYear}`);
expect(html).toContain(knownRef.org);
expect(html).toContain(`href="${knownRef.url}"`);
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noreferrer"');
expect(html).toContain("Source:");
});
it("renders the German title under de locale", () => {
const html = render(
buildInsight({
recommendations: [
{
text: "Aim for a target below 140/90.",
referenceId: knownRef.id,
},
],
}),
"de",
);
expect(html).toContain(knownRef.titleDe);
// German "Source" label
expect(html).toContain("Quelle:");
});
it("silently drops the footnote for an unknown referenceId (defence in depth)", () => {
// The schema would normally reject this payload, but if a stale
// payload sneaks through, the UI must not render a broken link.
const html = render(
buildInsight({
recommendations: [
{
text: "Aim for something.",
referenceId: "fabricated-2099",
},
],
}),
);
expect(html).toContain("Aim for something.");
expect(html).not.toContain('data-slot="insight-recommendation-source"');
expect(html).not.toContain("Source:");
});
it("renders multiple recs, each with their own footnote when applicable", () => {
const second = MEDICAL_REFERENCES[1] ?? knownRef;
const html = render(
buildInsight({
recommendations: [
{ text: "First rec", referenceId: knownRef.id },
{ text: "Second rec", referenceId: second.id },
"Third plain rec",
],
}),
);
expect(html).toContain("First rec");
expect(html).toContain("Second rec");
expect(html).toContain("Third plain rec");
expect(html).toContain(knownRef.url);
if (second !== knownRef) expect(html).toContain(second.url);
// The plain string rec produces no footnote.
const matches = html.match(/data-slot="insight-recommendation-source"/g);
expect(matches?.length ?? 0).toBe(second !== knownRef ? 2 : 2);
});
});
@@ -1,179 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: () => <div data-testid="health-chart-mock" />,
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart-mock" />,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
import { generateInsight } from "@/lib/ai/generate-insight";
import { MockAIProvider } from "@/lib/ai/mock-client";
/**
* v1.4.16 phase B5d — full-flow integration.
*
* MockAIProvider returns a payload where the model claims a high
* `confidence: 99`. The wrapper MUST overwrite that with the
* deterministic `computeConfidence()` value, which (with the default
* resolver: n=9, recencyDays=0, ratio=null) yields:
*
* nScore = 10 + 10*log10(9) ≈ 19.54
* recency = 30 (recencyDays=0)
* signal = 15 (null)
* total ≈ 64.54 → 65
*
* After generation, we mount InsightAdvisorCard with the parsed payload
* and assert the rendered ConfidenceMeter band corresponds to the
* server-computed value (medium/yellow), NOT the model's claimed 99
* (which would have rendered the green high band).
*/
const strictPayloadWithModelConfidence = {
summary: "BP runs slightly above baseline.",
recommendations: [
{
id: "rec-1",
text: "Discuss home BP log with your physician.",
severity: "important",
// Model claims very high confidence — wrapper overrides.
confidence: 99,
metricSource: {
type: "bloodPressure",
timeRange: "last7days",
summary: "avg 138/86 across 9 readings",
n: 9,
},
rationale: {
dataWindow: "last7days",
comparedTo: "your 90-day median (122/78)",
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
},
},
],
citations: [
{
type: "bloodPressure",
timeRange: "last7days",
summary: "avg 138/86 across 9 readings",
},
],
warnings: [],
};
describe("end-to-end — generateInsight() confidence override flows into the card", () => {
it("renders the deterministic confidence (NOT the model-supplied 99) on the card", async () => {
const provider = new MockAIProvider({
responses: JSON.stringify(strictPayloadWithModelConfidence),
});
const outcome = await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
const rec = outcome.parsed.recommendations[0];
// Server-computed value at n=9, recencyDays=0, ratio=null = 65
expect(rec.confidence).toBe(65);
expect(rec.confidence).not.toBe(99);
const insight: InsightResult = {
insightType: "general",
summary: outcome.parsed.summary,
classification: "grenzwertig",
classificationLabel: "Borderline",
findings: [],
correlations: [],
recommendations: outcome.parsed.recommendations.map((r) => ({
text: r.text,
referenceId: r.referenceId,
rationale: r.rationale,
metricSource: r.metricSource,
severity: r.severity,
id: r.id,
confidence: r.confidence,
})),
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
disclaimer: "Not a substitute for medical advice.",
};
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Test" insight={insight} />
</I18nProvider>,
);
// Score 65 → medium / yellow band, NOT high / green.
expect(html).toContain('data-confidence-band="medium"');
expect(html).not.toContain('data-confidence-band="high"');
// aria-label must announce the SERVER-computed score, not 99.
expect(html).toContain('aria-label="Confidence: 65 of 100"');
expect(html).not.toContain("99 of 100");
});
it("falls into the draft band when sample count is too thin (n<3)", async () => {
const tinyN = {
...strictPayloadWithModelConfidence,
recommendations: [
{
...strictPayloadWithModelConfidence.recommendations[0],
// Model still claims 99 — wrapper still discards.
confidence: 99,
metricSource: {
...strictPayloadWithModelConfidence.recommendations[0].metricSource,
n: 1,
},
},
],
};
const provider = new MockAIProvider({
responses: JSON.stringify(tinyN),
});
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual(
15,
);
const insight: InsightResult = {
insightType: "general",
summary: "x",
classification: "gut",
classificationLabel: "Good",
findings: [],
correlations: [],
recommendations: outcome.parsed.recommendations.map((r) => ({
text: r.text,
rationale: r.rationale,
metricSource: r.metricSource,
severity: r.severity,
id: r.id,
confidence: r.confidence,
})),
dataQuality: { coverage: "good", gaps: [], confidence: "gering" },
disclaimer: "x",
};
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Test" insight={insight} />
</I18nProvider>,
);
expect(html).toContain('data-confidence-band="draft"');
expect(html).toMatch(/Draft/);
});
});
@@ -1,193 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: ({
types,
title,
mini,
windowOverride,
}: {
types: string[];
title: string;
mini?: boolean;
windowOverride?: string;
}) => (
<div
data-testid="health-chart-mock"
data-types={types.join(",")}
data-mini={mini ? "true" : "false"}
data-window={windowOverride ?? ""}
>
chart:{title}
</div>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: ({ mini }: { mini?: boolean }) => (
<div data-testid="mood-chart-mock" data-mini={mini ? "true" : "false"} />
),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
import { generateInsight } from "@/lib/ai/generate-insight";
import { MockAIProvider } from "@/lib/ai/mock-client";
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
/**
* v1.4.16 phase B5c — full-flow integration.
*
* Mocks an AIProvider that returns a structured payload with
* rationale on every rec, runs `generateInsight()` to validate +
* parse, then feeds the parsed payload through InsightAdvisorCard.
* Asserts the advisor card surfaces the rec text + rationale rows
* via the RecommendationCard subcomponent.
*
* The advisor card consumes the legacy `InsightResult` shape, not
* the strict `AIInsightResponse` — but the new field schema is
* optional on the UI type, so a recommendation flowing in as a
* structured object with rationale renders the expand-card.
*/
const knownRefId = MEDICAL_REFERENCES[0].id;
const strictPayload = {
summary: "Your blood pressure is elevated against your own baseline.",
recommendations: [
{
id: "rec-1",
text: "Discuss home BP log with your physician.",
severity: "important",
metricSource: {
type: "bloodPressure",
timeRange: "last7days",
summary: "avg 138/86 across 9 readings",
n: 9,
},
rationale: {
dataWindow: "last7days",
comparedTo: "your 90-day median (122/78)",
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
},
referenceId: knownRefId,
},
],
citations: [
{
type: "bloodPressure",
timeRange: "last7days",
summary: "avg 138/86 across 9 readings",
},
],
warnings: [],
};
describe("end-to-end — generateInsight() + RecommendationCard render", () => {
it("strict payload from MockAIProvider passes parse + flows through to RecommendationCard rationale rows", async () => {
const provider = new MockAIProvider({
responses: JSON.stringify(strictPayload),
});
const outcome = await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
expect(outcome.parsed.recommendations).toHaveLength(1);
const rec = outcome.parsed.recommendations[0];
expect(rec.rationale).toEqual({
dataWindow: "last7days",
comparedTo: "your 90-day median (122/78)",
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
});
// Wrap as an InsightResult (legacy UI-shape) — only the
// recommendations need to carry the new fields for the rec card
// to render the expand panel.
const insight: InsightResult = {
insightType: "general",
summary: outcome.parsed.summary,
classification: "grenzwertig",
classificationLabel: "Borderline",
findings: [],
correlations: [],
recommendations: outcome.parsed.recommendations.map((r) => ({
text: r.text,
referenceId: r.referenceId,
rationale: r.rationale,
metricSource: r.metricSource,
severity: r.severity,
id: r.id,
})),
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
disclaimer: "Not a substitute for medical advice.",
};
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test"
insight={insight}
legacyPayload={false}
/>
</I18nProvider>,
);
// The rec card is collapsed by default — assert chevron present.
expect(html).toContain("Discuss home BP log");
expect(html).toMatch(/aria-expanded="false"/);
// Severity badge
expect(html).toContain("important");
});
it("legacyPayload flag surfaces the regenerate CTA", () => {
const insight: InsightResult = {
insightType: "general",
summary: "x",
classification: "gut",
classificationLabel: "Good",
findings: [],
correlations: [],
recommendations: ["Plain string rec"],
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
disclaimer: "Disclaimer",
};
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test"
insight={insight}
legacyPayload
onRegenerate={() => {}}
/>
</I18nProvider>,
);
expect(html).toContain('data-slot="insight-legacy-payload-cta"');
expect(html).toContain("Insights updated");
});
});
@@ -1,170 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult, InsightRecommendation } from "@/lib/ai/types";
/**
* v1.4.16 phase B1b — full-flow integration: rationale (B5c) +
* confidence (B5d) + feedback (B5e) + grid wrapper (B1b) all paint
* together inside InsightAdvisorCard.
*
* The B5c report flagged that an e2e couldn't be added because
* `/insights` doesn't currently mount InsightAdvisorCard in the
* production tree. This integration test exercises the same surface
* via SSR and pins the cross-feature interaction so the rec card
* shell + per-card affordances all render in the right slots when
* the polished grid sits between InsightAdvisorCard and the
* RecommendationCard.
*/
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: ({
types,
mini,
windowOverride,
}: {
types: string[];
mini?: boolean;
windowOverride?: string;
}) => (
<div
data-testid="health-chart"
data-types={types.join(",")}
data-mini={mini ? "true" : "false"}
data-window={windowOverride ?? ""}
/>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: ({ mini }: { mini?: boolean }) => (
<div data-testid="mood-chart" data-mini={mini ? "true" : "false"} />
),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function rec(
id: string,
severity: "info" | "suggestion" | "important" | "urgent",
text: string,
confidence: number,
): InsightRecommendation {
return {
id,
text,
severity,
confidence,
rationale: {
dataWindow: "last7days",
comparedTo: "your 90-day median",
deviation: "moderate deviation",
},
metricSource: {
type: "weight",
timeRange: "last7days",
summary: "84.2 kg avg over 7 days",
},
};
}
function buildInsight(recommendations: InsightRecommendation[]): InsightResult {
return {
insightType: "general",
summary: "Test summary",
classification: "gut",
classificationLabel: "Good",
findings: [],
correlations: [],
primaryRecommendation: "",
recommendations,
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
disclaimer: "Not medical advice.",
};
}
describe("InsightAdvisorCard + RecommendationsGrid full flow (B1b)", () => {
it("severity-orders the grid then paints confidence + feedback slots per card", () => {
const recs = [
rec("a", "info", "info-text", 22),
rec("b", "urgent", "urgent-text", 88),
rec("c", "suggestion", "suggestion-text", 60),
];
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="full flow" insight={buildInsight(recs)} />
</I18nProvider>,
);
// Grid wrapper present.
expect(html).toMatch(/data-slot="rec-grid"/);
// urgent appears before info in the rendered HTML.
expect(html.indexOf("urgent-text")).toBeLessThan(html.indexOf("info-text"));
expect(html.indexOf("urgent-text")).toBeGreaterThan(-1);
// Each card carries the named slots from B5c/d/e.
expect(html).toMatch(/data-slot="rec-confidence-slot"/);
expect(html).toMatch(/data-slot="confidence-meter"/);
// Severity-coloured borders all rendered (red, purple, cyan for the
// three severities we shipped).
expect(html).toMatch(/border-l-dracula-red/);
expect(html).toMatch(/border-l-dracula-purple/);
expect(html).toMatch(/border-l-dracula-cyan/);
// 3 stagger indices on the wrapper rows.
expect(html).toMatch(/data-stagger-index="0"/);
expect(html).toMatch(/data-stagger-index="1"/);
expect(html).toMatch(/data-stagger-index="2"/);
});
it("confidence below 25 swaps the meter for a draft pill (still respects sort order)", () => {
const recs = [
rec("a", "important", "important-text", 80),
rec("b", "urgent", "urgent-text", 12), // draft
];
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="draft" insight={buildInsight(recs)} />
</I18nProvider>,
);
// Urgent rec still sorts first even with low confidence.
expect(html.indexOf("urgent-text")).toBeLessThan(
html.indexOf("important-text"),
);
// Draft pill renders for confidence < 25.
expect(html).toMatch(/data-slot="confidence-meter"/);
expect(html).toContain("Draft");
});
it("renders an empty grid when there are no recommendations", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="empty" insight={buildInsight([])} />
</I18nProvider>,
);
// Grid is unmounted entirely when recs.length is 0.
expect(html).not.toContain('data-slot="rec-grid"');
// Recommendations heading not rendered without content underneath.
expect(html).not.toContain("Recommendations");
});
});
@@ -91,4 +91,29 @@ describe("<TrendAnnotation>", () => {
);
expect(html).toMatch(/data-metric="mood"/);
});
// ── v1.4.28 R3c-Insights — caption clamp contract (FB-K2) ─────────
it("clamps the filled-state caption to 3 lines so long annotations can't inflate the row", () => {
// Per Inv-3 the row used to grow with the longest caption; with
// `auto-rows-fr` propagating cell heights, a 4-line mood
// annotation pulled BP + weight cells taller. `line-clamp-3`
// is the bound — pinned here so a future "let the caption
// breathe" tweak has to re-evaluate the row rhythm.
const html = render(
<TrendAnnotation
metric="mood"
annotation="Mood ran steady across the back half of the week — three good days, one dip, then a recovery. The dip aligns with the Tuesday BP spike but the magnitude was small. Logged five mood entries in seven days, well above the 30-day median of three."
/>,
);
expect(html).toMatch(
/<p[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
);
});
it("clamps the empty-state caption to 3 lines too (same row contract)", () => {
const html = render(<TrendAnnotation metric="bp" annotation={null} />);
expect(html).toMatch(
/<p[^>]*data-slot="trend-annotation-empty"[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
);
});
});
@@ -67,6 +67,28 @@ describe("<TrendsRow>", () => {
expect(html).toMatch(/grid-cols-1/);
});
// ── v1.4.28 R3c-Insights — equal-height contract (FB-K1/K2) ───────
it("pins the row container to `auto-rows-fr` so every cell sits on the same row track", () => {
// Per Inv-3 the row already used `md:auto-rows-fr`. v1.4.28 lifts
// the modifier so the single-column path on phone-class viewports
// honours the same template. The class is load-bearing for the
// 3-slot tile contract — if a future refactor drops it the rows
// start collapsing on long annotations again.
const html = render(<TrendsRow />);
expect(html).toMatch(/\bauto-rows-fr\b/);
});
it("wraps each chart in a fixed-height chart slot (FB-K1 mood-tile alignment)", () => {
// The chart slot is the visible "where does the chart series
// start" landmark across BP / weight / mood. Wrapping the chart
// in `trends-row-chart-slot` lets us pin the chart-envelope
// height in one place instead of bleeding chart-component
// padding through the row.
const html = render(<TrendsRow />);
const slots = html.match(/data-slot="trends-row-chart-slot"/g) ?? [];
expect(slots.length).toBe(3);
});
it("renders annotations when supplied", () => {
const html = render(
<TrendsRow
@@ -1,150 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WeeklyReportPresentation } from "../weekly-report-view";
import type { WeeklyReport } from "@/lib/ai/schema";
/**
* v1.4.20 phase B4 — pure-presentational layer of the printable
* weekly report. The wrapper component (`WeeklyReportView`) consumes
* `useInsightsAdvisorQuery` + `useAuth`; the tests target the
* presentation slot directly so SSR rendering doesn't need a TanStack
* Query setup.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const sampleReport: WeeklyReport = {
weekISO: "2026-W19",
summary:
"Strong week — BP held under 130/85 on 9 of 10 readings and weight is down 0.6 kg.",
goingWell: [
"BP under 130/85 on 9 of 10 readings.",
"Compliance 94 % over the past 7 days.",
],
worthWatching: ["Monday-morning systolic +6 mmHg over 6 weeks."],
tips: ["Consider a brief walk after dinner on Mondays."],
dataQualityNotes: "Only 5 BP readings this week — n is borderline.",
};
describe("<WeeklyReportPresentation>", () => {
it("renders the article wrapper with the locale on lang", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report"/);
expect(html).toMatch(/lang="en"/);
});
it("renders the eyebrow chip + title with the week", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-eyebrow"/);
expect(html).toContain("Weekly report");
expect(html).toMatch(/data-slot="weekly-report-title"/);
expect(html).toContain("Your week — 2026-W19");
});
it("renders the date range derived from the ISO week", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-daterange"/);
// Range is May 04..May 10, 2026; locale-aware formatting picks dd/mm/yyyy
// for English. Anchor on the year so the assertion stays stable.
expect(html).toContain("2026");
});
it("renders all five sections when the report is fully populated", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-summary"/);
expect(html).toMatch(/data-slot="weekly-report-going-well"/);
expect(html).toMatch(/data-slot="weekly-report-worth-watching"/);
expect(html).toMatch(/data-slot="weekly-report-tips"/);
expect(html).toMatch(/data-slot="weekly-report-data-quality"/);
});
it("hides the going-well section when the array is empty", () => {
const html = render(
<WeeklyReportPresentation
weekISO="2026-W19"
report={{ ...sampleReport, goingWell: [] }}
/>,
);
expect(html).not.toContain('data-slot="weekly-report-going-well"');
});
it("hides the data-quality section when no notes were emitted", () => {
const html = render(
<WeeklyReportPresentation
weekISO="2026-W19"
report={{ ...sampleReport, dataQualityNotes: undefined }}
/>,
);
expect(html).not.toContain('data-slot="weekly-report-data-quality"');
});
it("renders the empty-state with a Generate CTA when report is null", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={null} />,
);
expect(html).toMatch(/data-slot="weekly-report-empty"/);
expect(html).toContain("No report for this week yet");
expect(html).toContain("Open Insights");
});
it("renders the toolbar with back link + print button", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-toolbar"/);
expect(html).toContain("Back to insights");
expect(html).toMatch(/data-slot="weekly-report-print"/);
expect(html).toContain("Print / Export PDF");
});
it("includes the not-medical-advice footer when report is present", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-footer"/);
expect(html).toContain("Not medical advice");
});
it("renders the German locale", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
"de",
);
expect(html).toMatch(/lang="de"/);
expect(html).toContain("Wochenbericht");
expect(html).toContain("Was gut läuft");
expect(html).toContain("Was Beachtung verdient");
});
it("applies print-friendly Tailwind variants on the article wrapper", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toContain("print:max-w-none");
expect(html).toContain("print:px-0");
});
it("hides the toolbar in print output via print:hidden", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
const toolbarMatch = html.match(
/<header[^>]*data-slot="weekly-report-toolbar"[^>]*>/,
);
expect(toolbarMatch).not.toBeNull();
expect(toolbarMatch?.[0]).toContain("print:hidden");
});
});
+28 -49
View File
@@ -8,14 +8,17 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
/**
* v1.4.27 R3d MB4 — Coach launch button.
* Inline desktop Coach launch button.
*
* Decision F: every routed Insights sub-page mounts this button so the
* Coach drawer (mounted at the layout root) is always reachable. The
* button renders as a sticky-bottom FAB on `<lg` viewports (so a phone
* user always sees it within thumb range) and as an inline header
* action on `lg+` (where the desktop drawer slides in from the right
* edge and the FAB would overlap the chart legend).
* v1.4.28 R3c — the legacy implementation rendered BOTH a sticky FAB
* (visible `<lg`) and the inline pill (`lg+`) from the same component.
* Every sub-page that mounted `<CoachLaunchButton>` therefore painted a
* second FAB into the DOM, collapsing to one visible button only
* because of `fixed` positioning — the duplicates still landed in the
* a11y tree. The FAB now lives once at the layout level (see
* `<LayoutCoachFab>` in `src/app/insights/layout.tsx`); this component
* keeps only the inline `lg+` pill that sub-pages mount inside their
* action rows.
*
* The button is a thin wrapper around `useCoachLaunch()` — it only
* renders when the context provider is mounted (so dropping the button
@@ -46,48 +49,24 @@ export function CoachLaunchButton({
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
// Inline header-style action on `lg+`. Hidden below `lg` because the
// layout-level FAB (`<LayoutCoachFab>`) covers that breakpoint —
// mounting both at the same time would put two affordances in front
// of the user.
return (
<>
{/* Sticky FAB on `<lg` viewports — pinned to the bottom-right so
the user's thumb always finds it. `bottom-20` clears the
mobile bottom navigation; `safe-area-inset-bottom` plus the
existing app shell padding keep it clear of the iOS home bar.
Hidden on `lg+` where the inline button below takes over. */}
<Button
type="button"
size="lg"
data-slot="coach-launch-fab"
onClick={() => launch.askCoach(prefill ?? null)}
aria-label={accessibleLabel}
title={accessibleLabel}
className={cn(
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
{/* Inline header-style action on `lg+`. Lives in the page body so
it picks up the sub-page layout's spacing instead of fighting
a sticky header overlay. */}
<Button
type="button"
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
className={cn(
"hidden h-10 gap-2 self-end lg:inline-flex",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
</>
<Button
type="button"
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
className={cn(
"hidden h-10 gap-2 self-end lg:inline-flex",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
);
}
@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.28 R3c (BK-F-M6) — pin the carved-out mobile rail tray's
* presentational contract. The carve-out is pure render: the parent
* owns open/closed state + the rail content; this component just
* paints two side-sheets with the right slots when open.
*
* The shadcn `<Sheet>` portals its content out of the React tree, so
* we mock the primitives down to plain wrappers (same pattern as
* `coach-settings-sheet.test.tsx`) to keep the slot assertions
* reachable in static markup.
*/
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-slot="mock-sheet">{children}</div> : null,
SheetContent: ({
children,
"data-slot": dataSlot,
className,
}: {
children: React.ReactNode;
"data-slot"?: string;
className?: string;
}) => (
<div data-slot={dataSlot} className={className}>
{children}
</div>
),
SheetHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
}));
import { MobileRailTray } from "../mobile-rail-tray";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseProps = {
historyOpen: true,
onHistoryOpenChange: () => undefined,
historyRail: <span>history-content</span>,
sourcesOpen: true,
onSourcesOpenChange: () => undefined,
sourcesRail: <span>sources-content</span>,
};
describe("<MobileRailTray>", () => {
it("renders both trays with their data-slot identifiers when open", () => {
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toContain('data-slot="coach-drawer-history-tray"');
expect(html).toContain('data-slot="coach-drawer-sources-tray"');
});
it("forwards the rail content slots verbatim", () => {
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toContain("history-content");
expect(html).toContain("sources-content");
});
it("hides the history tray on lg+ and the sources tray on xl+", () => {
// Matches the breakpoints documented inline: the history rail
// mounts inline at lg+, so the tray is `lg:hidden`. The sources
// rail mounts inline at xl+ only, so the tray is `xl:hidden`.
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toMatch(
/data-slot="coach-drawer-history-tray"[^>]*class="[^"]*lg:hidden/,
);
expect(html).toMatch(
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
);
});
it("renders both header titles localised", () => {
const enHtml = render(<MobileRailTray {...baseProps} />, "en");
expect(enHtml).toContain("Conversations");
expect(enHtml).toContain("What I can see");
const deHtml = render(<MobileRailTray {...baseProps} />, "de");
expect(deHtml).toContain("Unterhaltungen");
expect(deHtml).toContain("Worauf ich zugreife");
});
it("does not render either tray when its open prop is false", () => {
// The mock's `Sheet` returns `null` when `open === false`. Pin
// that contract so the carve-out stays gated on `historyOpen` /
// `sourcesOpen` and never paints orphan overlays.
const html = render(
<MobileRailTray
{...baseProps}
historyOpen={false}
sourcesOpen={false}
/>,
);
expect(html).not.toContain('data-slot="coach-drawer-history-tray"');
expect(html).not.toContain('data-slot="coach-drawer-sources-tray"');
});
});
@@ -30,6 +30,7 @@ import { CoachInput } from "./coach-input";
import { CoachSettingsSheet } from "./coach-settings-sheet";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
import { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
import type {
@@ -288,11 +289,14 @@ export function CoachDrawer({
"w-full p-0 sm:max-w-[720px]",
"lg:!max-w-[min(960px,75vw)] xl:!max-w-[1080px]",
isPhoneViewport
? // Bottom-sheet caps at 95 dvh so a sliver of the underlying
// /insights page remains visible — clear "this is a
// sheet, not a takeover" signal. The rounded top corners
// match the iOS bottom-sheet feel.
"flex h-[95dvh] max-h-[95dvh] flex-col gap-0 rounded-t-2xl"
? // Bottom-sheet caps at 90 dvh so a 10 % slice of the
// underlying /insights page stays visible — same
// convention as every other mobile sheet on the app via
// <ResponsiveSheet>'s phone branch (v1.4.28 R3c BK-M5
// alignment; previously 95 dvh, which left only a 5 %
// sliver and read as a takeover instead of a sheet).
// Rounded top corners match the iOS bottom-sheet feel.
"flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl"
: "flex h-[100dvh] flex-col gap-0",
)}
>
@@ -485,74 +489,52 @@ export function CoachDrawer({
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
{/* v1.4.20 phase B4 — mobile-only rail trays. Each renders a
slide-in panel from the matching edge and surfaces the same
HistoryRail / SourcesRail instance the desktop layout uses,
so a `>=lg` viewport never shows them but a `<lg` viewport
can summon either rail without leaving the message thread. */}
<Sheet open={historyTrayOpen} onOpenChange={setHistoryTrayOpen}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
)}
</div>
</SheetContent>
</Sheet>
{/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so
it doesn't conflict with the existing left/right rail
trays (those are <lg / <xl only; the settings sheet works
on every viewport). */}
it doesn't conflict with the existing rail trays (those are
<lg / <xl only; the settings sheet works on every
viewport). */}
<CoachSettingsSheet
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
<Sheet open={sourcesTrayOpen} onOpenChange={setSourcesTrayOpen}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
// Sources rail is xl+ inline; on lg it's only available
// via this tray to match the narrowed drawer cap.
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.sourcesTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? (
<SourcesRail
scope={{
sources: scope.sources,
window: effectiveWindow,
}}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)}
</div>
</SheetContent>
</Sheet>
{/* v1.4.28 R3c (BK-F-M6) — mobile-only rail trays carved out of
the drawer shell. Each tray surfaces the same HistoryRail /
SourcesRail instance the desktop layout uses; the parent
still owns the open/closed state + the rail callbacks so
the carve-out is pure render. */}
<MobileRailTray
historyOpen={historyTrayOpen}
onHistoryOpenChange={setHistoryTrayOpen}
historyRail={
historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
)
}
sourcesOpen={sourcesTrayOpen}
onSourcesOpenChange={setSourcesTrayOpen}
sourcesRail={
sourcesRail ?? (
<SourcesRail
scope={{
sources: scope.sources,
window: effectiveWindow,
}}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)
}
/>
</SheetContent>
</Sheet>
);
@@ -0,0 +1,87 @@
"use client";
import type { ReactNode } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.20 phase B4 — mobile-only rail trays for the Coach drawer.
*
* The desktop layout pins the history rail (lg+) and the sources rail
* (xl+) alongside the message thread. Below those breakpoints the
* rails are reachable via chevron triggers in the thread's sub-header
* strip; each trigger opens the matching side-sheet here.
*
* v1.4.28 R3c (BK-F-M6) — carved out of `<CoachDrawer>` (~560 LOC).
* The parent still owns the rail content + the open/closed state for
* each tray so this component stays a thin presentational shell that
* can be unit-tested in isolation without standing up the drawer's
* snapshot fetch + scope-picker hooks.
*
* The component takes pre-rendered rail nodes rather than the raw
* rail components so the caller controls how each rail's
* `onSelect` / `onScopeChange` callbacks close the tray (some flows
* dismiss the tray after a selection; the contract for that lives in
* the parent, not here).
*/
export interface MobileRailTrayProps {
historyOpen: boolean;
onHistoryOpenChange: (next: boolean) => void;
historyRail: ReactNode;
sourcesOpen: boolean;
onSourcesOpenChange: (next: boolean) => void;
sourcesRail: ReactNode;
}
export function MobileRailTray({
historyOpen,
onHistoryOpenChange,
historyRail,
sourcesOpen,
onSourcesOpenChange,
sourcesRail,
}: MobileRailTrayProps) {
const { t } = useTranslations();
return (
<>
{/* History tray — slides in from the left edge. `lg:hidden`
because the history rail is inline on lg+. */}
<Sheet open={historyOpen} onOpenChange={onHistoryOpenChange}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">{historyRail}</div>
</SheetContent>
</Sheet>
{/* Sources tray — slides in from the right edge. `xl:hidden`
because the sources rail is inline on xl+. */}
<Sheet open={sourcesOpen} onOpenChange={onSourcesOpenChange}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.sourcesTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">{sourcesRail}</div>
</SheetContent>
</Sheet>
</>
);
}
+6 -1
View File
@@ -334,10 +334,15 @@ export function DailyBriefing({
description={t("insights.dailyBriefing.emptyDescription")}
action={
onRegenerate ? (
// v1.4.28 BK-M2 — switch the empty-state CTA from
// `variant="outline"` to the default (filled) variant
// so the briefing card matches the dashboard
// empty-state CTA shape. Empty-state actions on a card
// surface read as the single primary affordance and
// earn the filled chip across surfaces in v1.4.28.
<Button
type="button"
size="sm"
variant="outline"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-empty-cta"
+35 -5
View File
@@ -4,9 +4,10 @@ import { useId, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
/**
* v1.4.20 phase B5 — Personal Health Score panel.
* v1.4.20 B5 — Personal Health Score panel.
*
* Lives on the right side of the `<HeroStrip>` band on `lg+` viewports
* and stacks below the title block on `<lg`. Surfaces:
@@ -183,6 +184,11 @@ export function HealthScoreCard({
// the card is mounted twice on the same page (lg+ hero strip vs
// smaller stacked previews in tests).
const panelId = useId();
// FB-I1 a11y — id wired from the delta `<span>` (aria-describedby)
// to the explainer popover/sheet body. The thread lets screen
// readers connect "−3 vs last week" to the three-sentence read
// without relying on visual proximity alone.
const deltaExplainerId = useId();
const componentEntries = (
Object.keys(components) as Array<ComponentKey>
@@ -241,9 +247,16 @@ export function HealthScoreCard({
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
// narrows.
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
// v1.4.28 R3c-Insights — stretch to match the parent's
// `items-stretch` row height (FB-H1/H2). `h-full` + an inner
// flex column lets the disclaimer ride to the bottom with
// `mt-auto` so the headline number stays at the top and the
// recovered vertical space pads through the middle rather
// than under the disclaimer.
"flex h-full flex-col",
)}
>
<div className="flex flex-col gap-3">
<div className="flex flex-1 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p
@@ -312,6 +325,13 @@ export function HealthScoreCard({
/>
</div>
{/* v1.4.28 R3c-Insights — FB-I1 — the delta line gets a
sibling `?` glyph that opens a 3-sentence read of which
components contributed, what the comparison window is,
and one concrete next step. Popover on `md+`, bottom-sheet
on phone-class viewports. The explainer only mounts when
a numeric delta is available; the "no history yet"
branch keeps the existing single-line caption. */}
<p
data-slot="health-score-card-delta"
className="text-muted-foreground inline-flex items-center gap-1 text-[11px]"
@@ -338,11 +358,15 @@ export function HealthScoreCard({
aria-hidden="true"
/>
)}
<span>
<span aria-describedby={deltaExplainerId}>
{t("insights.healthScore.deltaVsLastWeek", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
</span>
<HealthScoreDeltaExplainer
delta={delta}
bodyId={deltaExplainerId}
/>
</>
)}
</p>
@@ -545,10 +569,16 @@ export function HealthScoreCard({
carries an "Ask the coach" action so the inline button
here retires; the parent still receives onAskCoach via
other surfaces but the card no longer mounts a duplicate
CTA. */}
CTA.
v1.4.28 R3c-Insights — `mt-auto` pins the disclaimer to
the bottom of the stretched card so the score number and
sub-bars stay anchored at the top while the recovered
vertical space (when the parent column is taller) sits
quietly between the provenance accordion and the
disclaimer footer. */}
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[11px] leading-snug"
className="text-muted-foreground mt-auto text-[11px] leading-snug"
>
{t("insights.healthScore.disclaimer")}
</p>
@@ -0,0 +1,160 @@
"use client";
import { useId, useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
*
* FB-I1: the user asked why the headline number moved. The earlier
* delta line surfaced the digit ("-3 vs last week") without any
* mention of which components shifted, what the comparison window
* actually is, or what the user can do about it. The explainer is a
* single icon-only `?` button next to the delta line that opens a
* three-sentence read.
*
* Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet
* on phone-class viewports. Both share the same body copy via the
* `insights.healthScore.deltaExplainer.body` key; the title is
* surfaced as the popover-content first line on desktop and as the
* sheet header on mobile (the ResponsiveSheet primitive already
* paints a header band on the sheet branch).
*
* Pure presentational. Owns its own open state — the parent doesn't
* need to thread an extra prop through.
*/
interface HealthScoreDeltaExplainerProps {
/**
* The delta value the parent already painted on the line above. The
* explainer doesn't repaint the digit; it sits next to the line
* with the `?` glyph and lets the user request the prose.
*/
delta: number;
/**
* Optional className for the trigger button so the parent can
* align the glyph to the delta line's baseline.
*/
className?: string;
/**
* Optional id the parent owns and threads to the explainer body via
* the popover/sheet markup. The same id sits on the parent's delta
* `<span>` as `aria-describedby` so screen readers can connect "−3
* vs last week" to the three-sentence read on demand.
*/
bodyId?: string;
}
export function HealthScoreDeltaExplainer({
delta,
className,
bodyId,
}: HealthScoreDeltaExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
// Stable fallback id when the parent doesn't supply one. The body
// still paints the id so future consumers can thread the same
// describedby pattern without modifying the explainer.
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
const title = t("insights.healthScore.deltaExplainer.title");
const body = t("insights.healthScore.deltaExplainer.body");
// Direct click handler on the button on both branches. Earlier
// mobile path wrapped the button in a `<span onClick onKeyDown>`
// which created two interactive elements in the a11y tree and
// intercepted clicks on the 2 px gap around the button. The button
// already handles Enter/Space natively; the only thing the wrapper
// owned was the open toggle, which moves onto the button itself.
const handleOpen = () => setOpen(true);
const trigger = (
<button
type="button"
data-slot="health-score-delta-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={resolvedBodyId}
className={cn(
// Visible glyph stays 12 px so the chip reads at the delta
// line's height without inflating the row. The button itself
// sits at the 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`;
// negative `-my-3 -mx-2` collapses the surrounding row back
// to its 16 px stride so the optical chip stays small while
// the hit surface lifts to 44 px. Transparent padding owns
// the extra reach.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={handleOpen}
>
<HelpCircle className="h-3 w-3" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={title}
description={t(
"insights.healthScore.deltaExplainer.description",
{ delta: delta > 0 ? `+${delta}` : `${delta}` },
)}
>
<p
id={bodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="health-score-delta-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="health-score-delta-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-[11px] leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
);
}
+22 -180
View File
@@ -1,8 +1,6 @@
"use client";
import Link from "next/link";
import { toast } from "sonner";
import { Download, FileText, Share2, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -24,11 +22,11 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
* "Guten Morgen, …") + a narrative subtitle (the briefing
* paragraph when one is cached, else a fallback)
* - personal-baseline + "Generated <relative-time>" meta row
* - action row: "Generate weekly report" + "Ask the coach"
* (v1.4.25 W3 dropped the third "Re-run analysis" button — the
* regenerate affordance moved to `<InsightsTabStrip>` so the user
* can re-run the analysis from any scroll position without
* returning to the hero band).
* - action row: single "Ask the coach" affordance
* (v1.4.25 W3 dropped the "Re-run analysis" button — the
* regenerate affordance moved to `<InsightsTabStrip>`. v1.4.28
* retired the weekly-report path, leaving Coach as the sole
* hero-row action.)
* - <SuggestedPrompts> chip strip below the action band
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
* + `.glow-purple` utilities in `globals.css`
@@ -70,23 +68,6 @@ interface HeroStripProps {
* panel calls it with "Why is my health score X out of 100?".
*/
onAskCoach?: (prefill?: string) => void;
/**
* v1.4.20 phase B4 — when the cached AI payload carries a fresh
* weeklyReport block, the parent passes this so the hero paints a
* slim banner card ("Your Week N report is ready" with Read · Share
* · Export PDF actions). Omit to hide the banner.
*/
weeklyReportReady?: {
weekISO: string;
href: string;
};
/**
* v1.4.20 phase D reconcile — href to the current week's report. When
* supplied, the action-row "Generate weekly report" button becomes a
* real link instead of the disabled placeholder. B4 shipped the
* route, so the button should not paint as disabled-primary anymore.
*/
weeklyReportHref?: string;
/**
* Now() override for tests so the greeting bucket is deterministic.
* Defaults to `new Date()`. Production callers omit this.
@@ -133,8 +114,6 @@ export function HeroStrip({
userName,
onPickPrompt,
onAskCoach,
weeklyReportReady,
weeklyReportHref,
now,
healthScore,
}: HeroStripProps) {
@@ -172,11 +151,23 @@ export function HeroStrip({
* `lg:` modifiers alongside so existing snapshot/assertion tests
* that grep for `lg:flex-row` continue to find it.
*/}
{/*
* v1.4.28 R3c-Insights — switch the row's cross-axis alignment
* from `items-start` to `items-stretch` so the right-column
* HealthScore card grows to match the left column's natural
* height (greeting + subtitle + baseline meta + action row +
* suggested-prompts strip). Per Inv-4 the card was painting
* 75-110 px shorter than the left column on desktop; the
* stretch contract pulls the card's bottom edge down to the
* "Wirkt mein Medikament?" chip. The card itself owns
* `h-full` + `mt-auto` on its disclaimer to redistribute the
* recovered space without visual jank.
*/}
<div
className={cn(
"flex flex-col gap-5",
healthScore &&
"md:flex-row md:items-start md:gap-6 lg:flex-row lg:items-start lg:gap-6",
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
@@ -216,58 +207,13 @@ export function HeroStrip({
</div>
</div>
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
)}
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D
* reconcile enables this button as a real link to the
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{(() => {
// v1.4.22 W5 reconcile (S-02) — collapse the
// enabled/disabled twin Buttons (variant, size, slot,
// class, icon, label all identical) into a single
// shared structure with the Link wrapping conditionally.
const weeklyLabel = (
<>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</>
);
return weeklyReportHref ? (
<Button
asChild
variant="default"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>{weeklyLabel}</Link>
</Button>
) : (
<Button
type="button"
variant="default"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
{weeklyLabel}
</Button>
);
})()}
{/* B2b wires this into the Coach drawer. The button is
enabled whenever the parent supplies an `onAskCoach`
handler; older parents that haven't adopted B2b yet
still get the disabled "Coming soon" affordance so the
hero doesn't break. */}
hero doesn't break. v1.4.28 retired the weekly-report
button, leaving Coach as the only hero-row action. */}
<Button
type="button"
variant="outline"
@@ -284,8 +230,7 @@ export function HeroStrip({
* v1.4.25 W3 — the regenerate button moved to the new
* `<InsightsTabStrip>` (icon-only RefreshCw, sticky next
* to the pill nav) so the user can re-run the analysis
* without scrolling back to the hero band. The hero's
* action row is now Weekly-report + Ask-the-Coach only.
* without scrolling back to the hero band.
*/}
</div>
@@ -318,106 +263,3 @@ export function HeroStrip({
</div>
);
}
/**
* v1.4.20 phase B4 — slim banner card surfacing the fresh weekly report.
*
* Sits between the hero's title block and the action row. The banner
* carries three actions:
* - Read → in-app navigation to `/insights/report/[week]`.
* - Share → `navigator.share` when supported, else clipboard fallback
* with a sonner toast acknowledgement.
* - Export PDF → opens the report URL with `?print=1` so the report
* page auto-fires `window.print()` after first paint.
*/
function WeeklyReportBanner({
weekISO,
href,
}: {
weekISO: string;
href: string;
}) {
const { t } = useTranslations();
const printHref = href.includes("?") ? `${href}&print=1` : `${href}?print=1`;
const shareUrl =
typeof window !== "undefined"
? new URL(href, window.location.origin).toString()
: href;
async function handleShare() {
const title = t("insights.heroBanner.shareTitle");
if (typeof navigator !== "undefined" && "share" in navigator) {
try {
await navigator.share({ url: shareUrl, title });
return;
} catch (err) {
// Web Share rejects with AbortError when the user cancels —
// not an error worth surfacing. Other failures fall through to
// the clipboard fallback below.
if ((err as DOMException)?.name === "AbortError") return;
}
}
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t("insights.heroBanner.shareCopied"));
return;
} catch {
// fallthrough
}
}
toast.error(t("insights.heroBanner.shareFailed"));
}
return (
<div
data-slot="insights-hero-strip-weekly-banner"
className="border-dracula-purple/30 bg-dracula-purple/10 flex flex-wrap items-center gap-3 rounded-lg border px-3 py-2.5 sm:px-4"
>
<Sparkles
className="text-dracula-purple h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p
data-slot="insights-hero-strip-weekly-banner-label"
className="min-w-0 flex-1 text-sm leading-snug"
>
{t("insights.heroBanner.ready", { week: weekISO })}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Button
asChild
variant="default"
data-slot="insights-hero-strip-weekly-banner-read"
className="gap-1.5"
>
<Link href={href}>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroBanner.read")}</span>
</Link>
</Button>
<Button
type="button"
variant="ghost"
onClick={handleShare}
data-slot="insights-hero-strip-weekly-banner-share"
className="gap-1.5"
>
<Share2 className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroBanner.share")}</span>
</Button>
<Button
asChild
variant="ghost"
data-slot="insights-hero-strip-weekly-banner-export"
className="gap-1.5"
>
<Link href={printHref}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroBanner.exportPdf")}</span>
</Link>
</Button>
</div>
</div>
);
}
@@ -1,776 +0,0 @@
"use client";
import { useState } from "react";
import type { InsightResult, InsightFinding } from "@/lib/ai/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Sparkles,
RefreshCw,
Loader2,
CheckCircle2,
Minus,
AlertCircle,
AlertTriangle,
ChevronDown,
ChevronUp,
Info,
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { HealthChart } from "@/components/charts/health-chart";
import { MoodChart } from "@/components/charts/mood-chart";
import {
parseChartTokens,
stripChartTokens,
tokenKind,
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
import { RecommendationsGrid } from "./recommendations-grid";
// ─── Types ────────────────────────────────────────────────
interface InsightAdvisorCardProps {
/**
* v1.4.19 A3 — optional subtitle below the CardTitle. The
* `/insights` page surface no longer passes a subtitle (the
* CardTitle alone is enough framing); the prop stays optional so
* existing test fixtures + the dashboard preview keep their
* supplementary line. When omitted the `<p>` element is dropped
* entirely so the empty card no longer renders an "orphan
* Persönlicher Berater" title above an empty body.
*/
title?: string;
icon?: React.ReactNode;
insight: InsightResult | null;
loading?: boolean;
error?: string | null;
onRegenerate?: () => void;
regenerating?: boolean;
cachedAt?: string | null;
/**
* v1.4.16 phase B5c — set when the cached payload predates B5c
* rationale. The card surfaces a one-time CTA pointing at
* regenerate; user-initiated only.
*/
legacyPayload?: boolean;
}
// ─── Classification Colors ───────────────────────────────
const CLASSIFICATION_STYLES: Record<string, { badge: string; border: string }> =
{
optimal: {
badge:
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
border: "border-l-dracula-green",
},
gut: {
badge:
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
border: "border-l-dracula-cyan",
},
grenzwertig: {
badge:
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
border: "border-l-dracula-yellow",
},
erhoht: {
badge:
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
border: "border-l-dracula-orange",
},
kritisch: {
badge:
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
border: "border-l-dracula-red",
},
};
// ─── Assessment Icons ─────────────────────────────────────
function AssessmentIcon({
assessment,
}: {
assessment: InsightFinding["assessment"];
}) {
switch (assessment) {
case "positive":
return <CheckCircle2 className="text-dracula-green h-4 w-4 shrink-0" />;
case "neutral":
return <Minus className="text-muted-foreground h-4 w-4 shrink-0" />;
case "attention":
return <AlertCircle className="text-dracula-orange h-4 w-4 shrink-0" />;
case "warning":
return <AlertTriangle className="text-dracula-red h-4 w-4 shrink-0" />;
}
}
// ─── Hero Finding ─────────────────────────────────────────
const HERO_STYLES: Record<
InsightFinding["assessment"],
{ wrapper: string; icon: string; label: string }
> = {
positive: {
wrapper:
"from-dracula-green/15 via-dracula-green/5 to-transparent border-l-dracula-green",
icon: "text-dracula-green",
label: "text-dracula-green",
},
neutral: {
wrapper:
"from-dracula-cyan/12 via-dracula-cyan/4 to-transparent border-l-dracula-cyan",
icon: "text-dracula-cyan",
label: "text-dracula-cyan",
},
attention: {
wrapper:
"from-dracula-orange/15 via-dracula-orange/5 to-transparent border-l-dracula-orange",
icon: "text-dracula-orange",
label: "text-dracula-orange",
},
warning: {
wrapper:
"from-dracula-red/15 via-dracula-red/5 to-transparent border-l-dracula-red",
icon: "text-dracula-red",
label: "text-dracula-red",
},
};
function HeroFinding({ finding }: { finding: InsightFinding }) {
const { t } = useTranslations();
const style = HERO_STYLES[finding.assessment];
const labelKey =
finding.assessment === "positive"
? "insights.heroFindingPositive"
: finding.assessment === "warning"
? "insights.heroFindingWarning"
: finding.assessment === "attention"
? "insights.heroFindingAttention"
: "insights.heroFindingNeutral";
const labelTokens = parseChartTokens(finding.label);
const cleanLabel = stripChartTokens(finding.label);
const guidelineTokens = finding.guideline
? parseChartTokens(finding.guideline)
: [];
const cleanGuideline = finding.guideline
? stripChartTokens(finding.guideline)
: "";
return (
<div
data-slot="insight-hero-finding"
className={`rounded-lg border-l-2 bg-gradient-to-br ${style.wrapper} px-4 py-3.5`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<div className={`mt-0.5 shrink-0 ${style.icon}`}>
<AssessmentIcon assessment={finding.assessment} />
</div>
<div className="min-w-0 space-y-1">
<p
className={`text-xs font-semibold tracking-widest uppercase ${style.label}`}
>
{t(labelKey)}
</p>
<p className="text-sm leading-snug font-medium">{cleanLabel}</p>
{cleanGuideline && (
<p className="text-muted-foreground text-xs leading-snug">
{cleanGuideline}
</p>
)}
</div>
</div>
<span className="shrink-0 text-base leading-snug font-semibold tabular-nums">
{finding.value}
</span>
</div>
{(labelTokens.length > 0 || guidelineTokens.length > 0) && (
<div className="mt-3">
<InlineCharts tokens={[...labelTokens, ...guidelineTokens]} />
</div>
)}
</div>
);
}
// ─── Confidence Colors ────────────────────────────────────
const CONFIDENCE_STYLES: Record<string, string> = {
hoch: "text-dracula-green",
mittel: "text-dracula-yellow",
gering: "text-dracula-orange",
};
const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
hoch: "insights.confidenceHoch",
mittel: "insights.confidenceMittel",
gering: "insights.confidenceGering",
};
// ─── Inline Chart Renderer ────────────────────────────────
//
// Expand a list of allowlisted ChartTokens into HealthChart instances.
// HealthChart already understands the `MeasurementType`-style strings
// (WEIGHT, BLOOD_PRESSURE_SYS, …); the synthetic MOOD and COMPLIANCE
// tokens map to the dedicated chart components shipped elsewhere — for
// now we render them as a HealthChart with the token's metric name as
// the type, which the chart will gracefully render empty if there's no
// matching data, instead of silently swallowing the user's expectation.
//
// Tokens are deduped per call so the prose `metric:WEIGHT … metric:WEIGHT`
// doesn't render the same chart twice in a row.
const INLINE_CHART_TITLE_KEYS: Record<string, string> = {
WEIGHT: "charts.weight",
BLOOD_PRESSURE_SYS: "charts.systolic",
BLOOD_PRESSURE_DIA: "charts.diastolic",
PULSE: "charts.pulse",
BODY_FAT: "charts.bodyFat",
SLEEP_DURATION: "charts.sleep",
ACTIVITY_STEPS: "charts.steps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
TOTAL_BODY_WATER: "charts.bodyWater",
BONE_MASS: "charts.boneMass",
OXYGEN_SATURATION: "charts.spo2",
MOOD: "insights.navMood",
COMPLIANCE: "insights.navMedication",
};
function InlineCharts({
tokens,
mini = false,
}: {
tokens: ChartToken[];
/**
* v1.4.16 phase B1b — render the embedded charts as compact
* sparklines (no range tabs, h-[140px]). The summary surface uses
* this so the prose stays the headline; the finding-card surface
* keeps the full chart so the user can interact with the data.
*/
mini?: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
if (tokens.length === 0) return null;
// Dedup while preserving the order the model emitted them.
const seen = new Set<ChartToken>();
const unique: ChartToken[] = [];
for (const token of tokens) {
if (!seen.has(token)) {
seen.add(token);
unique.push(token);
}
}
return (
<div data-slot="insight-inline-charts" className="space-y-3">
{unique.map((token) => {
const metric = tokenToMetric(token);
const kind = tokenKind(token);
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
const title = titleKey ? t(titleKey) : metric;
return (
<div
key={token}
data-slot="insight-inline-chart"
data-metric={metric}
data-mini={mini ? "true" : "false"}
>
{kind === "mood" ? (
<MoodChart mini={mini} userTimezone={user?.timezone} />
) : (
<HealthChart
types={[metric]}
title={title}
mini={mini}
userTimezone={user?.timezone}
/>
)}
</div>
);
})}
</div>
);
}
// ─── Section Separator ────────────────────────────────────
// ─── Section Separator ────────────────────────────────────
function SectionSeparator({ label }: { label: string }) {
return (
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
<span>{label}</span>
<div className="bg-border h-px flex-1" />
</div>
);
}
// ─── Main Component ───────────────────────────────────────
export function InsightAdvisorCard({
title,
icon,
insight,
loading = false,
error = null,
onRegenerate,
regenerating = false,
cachedAt,
legacyPayload = false,
}: InsightAdvisorCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const [dataQualityOpen, setDataQualityOpen] = useState(false);
const classStyle = insight
? (CLASSIFICATION_STYLES[insight.classification] ??
CLASSIFICATION_STYLES.gut)
: CLASSIFICATION_STYLES.gut;
// ── Loading State ─────────────────────────────────────
// v1.4.16 phase B1b — skeleton mirrors the final card layout (header
// pulse + 3 placeholder rec rows) so the page doesn't visually jump
// when content lands. Animation keys off Tailwind `animate-pulse`,
// which `prefers-reduced-motion: reduce` already silences via the
// `motion-reduce:animate-none` utility on the same elements.
if (loading && !insight) {
return (
<Card data-slot="insight-skeleton">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/60 h-4 w-3/4 animate-pulse rounded-md motion-reduce:animate-none" />
<div className="bg-muted/50 h-3 w-2/3 animate-pulse rounded-md motion-reduce:animate-none" />
<div className="grid gap-3 lg:grid-cols-2">
{[0, 1, 2].map((i) => (
<div
key={i}
data-slot="insight-skeleton-card"
className="bg-muted/40 h-20 animate-pulse rounded-lg motion-reduce:animate-none"
style={{ animationDelay: `${i * 100}ms` }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ── Empty / No Data State ─────────────────────────────
if (!insight) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent>
{error && (
<div
data-slot="insight-error-state"
className="border-destructive/30 bg-destructive/10 text-destructive flex flex-col items-start gap-3 rounded-lg border p-4 text-sm"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
{onRegenerate && (
<Button
variant="outline"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insight-retry-button"
className="gap-1.5 self-start"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("insights.refreshAnalysis")}
</Button>
)}
</div>
)}
{!error && (
<div
data-slot="insight-empty-state"
className="flex flex-col items-center gap-3 py-6 text-center"
>
<div className="bg-dracula-purple/10 flex h-12 w-12 items-center justify-center rounded-full">
<Sparkles
className="text-dracula-purple h-6 w-6"
aria-hidden="true"
/>
</div>
<p className="text-muted-foreground max-w-prose text-sm">
{t("insights.noAnalysisYet")}
</p>
{onRegenerate && (
<Button
variant="outline"
onClick={onRegenerate}
disabled={regenerating}
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{t("insights.startAnalysis")}
</Button>
)}
</div>
)}
</CardContent>
</Card>
);
}
// ── Legacy-Payload State ──────────────────────────────
// v1.4.17 hotfix: when the cached blob predates the v1.4.16 strict
// schema (no `summary`, no `recommendations[]`, no `findings[]` —
// the v1.4.14 `{changed, stable, drivers, ...}` shape), the rich-card
// render path below would crash on `stripChartTokens(undefined)`.
// Surface the regenerate CTA in a self-contained card and skip the
// rich body entirely. The `legacyPayload` prop is the explicit
// signal from the API; we also defensively short-circuit when the
// shape doesn't carry the modern `summary` string at all.
const isUnrenderable =
typeof insight.summary !== "string" ||
!Array.isArray(insight.findings) ||
!Array.isArray(insight.recommendations);
if (legacyPayload || isUnrenderable) {
return (
<Card data-slot="insight-legacy-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent>
<div
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-sm"
data-slot="insight-legacy-payload-cta"
>
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-2">
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3 w-3" />
)}
{t("insights.heroRegenerate")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
// ── Full Insight Card ─────────────────────────────────
return (
<Card className="animate-insight-in">
{/* Header */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge className={classStyle.badge}>
{insight.classificationLabel ?? insight.classification}
</Badge>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent className="space-y-5">
{/* Error banner */}
{error && (
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Primary Recommendation — prose first, then any inline charts the
* model emitted via `metric:<TYPE>` tokens. Tokens are stripped from
* the visible text so they never leak into the hero card. */}
{insight.primaryRecommendation && (
<div className="border-dracula-purple bg-dracula-purple/5 space-y-3 rounded-md border-l-2 px-4 py-3">
<p className="text-dracula-purple mb-1 text-xs font-medium tracking-widest uppercase">
{t("insights.keyTakeaway")}
</p>
<p className="text-sm">
{stripChartTokens(insight.primaryRecommendation)}
</p>
<InlineCharts
tokens={parseChartTokens(insight.primaryRecommendation)}
/>
</div>
)}
{/* Summary — prose first, then any allowlisted charts the model
* inlined via `metric:<TYPE>` tokens. Tokens are dropped from the
* visible text so the literal substring never reaches the DOM.
*
* v1.4.16 phase B1b — typography polish: larger font (text-base),
* relaxed line-height, max-w-prose so the summary doesn't span
* full-bleed on a wide viewport. Wraps the inline-chart strip
* in the same constrained column so the rendered sparklines
* align with the prose. */}
<div
data-slot="insight-summary"
className="text-foreground/90 max-w-prose space-y-3 text-base leading-relaxed"
>
<p>{stripChartTokens(insight.summary)}</p>
<InlineCharts tokens={parseChartTokens(insight.summary)} mini />
</div>
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
{insight.findings.length > 0 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.findingsTitle")} />
{/* v1.4 — pull the first finding into a severity-tinted hero
* card. The model is instructed to put the most clinically
* relevant finding first; we let the UI honour that by giving
* it more weight than the secondary findings.
*/}
<HeroFinding finding={insight.findings[0]} />
{insight.findings.length > 1 && (
<div className="space-y-2 pt-1">
{insight.findings.slice(1).map((finding, i) => {
const labelTokens = parseChartTokens(finding.label);
const guidelineTokens = finding.guideline
? parseChartTokens(finding.guideline)
: [];
const tokens = [...labelTokens, ...guidelineTokens];
return (
<div key={i}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AssessmentIcon assessment={finding.assessment} />
<span className="text-sm">
{stripChartTokens(finding.label)}
</span>
</div>
<span className="text-sm font-medium tabular-nums">
{finding.value}
</span>
</div>
{finding.guideline && (
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
{stripChartTokens(finding.guideline)}
</p>
)}
{tokens.length > 0 && (
<div className="mt-2 ml-6">
<InlineCharts tokens={tokens} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* Correlations */}
{insight.correlations.length > 0 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.correlationsTitle")} />
<div className="flex flex-wrap gap-2">
{insight.correlations.map((corr, i) => (
<div
key={i}
className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
>
<span>{corr.factor}</span>
<span className="text-muted-foreground">↔</span>
<span>{corr.effect}</span>
<span
className={`ml-1 font-medium ${CONFIDENCE_STYLES[corr.confidence] ?? ""}`}
>
{corr.confidence}
</span>
</div>
))}
</div>
</div>
)}
{/* v1.4.16 phase B5c — legacy-payload CTA. Surfaced when the
* cached payload predates the rationale field; pointing the
* user at the regenerate button. Click-handler is owned by
* the parent (the regenerate button). */}
{legacyPayload && onRegenerate && (
<div
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
data-slot="insight-legacy-payload-cta"
>
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1.5">
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3 w-3" />
)}
{t("insights.heroRegenerate")}
</Button>
</div>
</div>
)}
{/* Recommendations — v1.4.16 phase B1b polish: severity-ordered
* 2-col responsive grid via <RecommendationsGrid>. Each card
* keeps the Oura-style expand reveal from B5c and gains a
* severity-coloured left border + hover-lift + staggered
* fade-in around its outer shell. */}
{insight.recommendations.length > 0 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.recommendationsTitle")} />
<RecommendationsGrid recs={insight.recommendations} />
</div>
)}
{/* Data Quality (collapsible) */}
{insight.dataQuality && (
<div>
<button
onClick={() => setDataQualityOpen((v) => !v)}
aria-expanded={dataQualityOpen}
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 text-xs transition-colors"
>
{dataQualityOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>{t("insights.dataFoundation")}</span>
<span className="mx-1">·</span>
<span>
{t("insights.confidence")}:{" "}
<span
className={`font-medium ${CONFIDENCE_STYLES[insight.dataQuality.confidence] ?? ""}`}
>
{t(
CONFIDENCE_LABEL_KEYS[insight.dataQuality.confidence] ??
"insights.confidenceMittel",
)}
</span>
</span>
</button>
{dataQualityOpen && (
<div className="bg-muted/30 text-muted-foreground animate-insight-in mt-2 space-y-2 rounded-md px-3 py-2.5 text-xs">
<p>{insight.dataQuality.coverage}</p>
{insight.dataQuality.gaps.length > 0 && (
<div>
<p className="font-medium">{t("insights.dataGaps")}</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{insight.dataQuality.gaps.map((gap, i) => (
<li key={i}>{gap}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
{cachedAt && (
<p className="text-muted-foreground text-xs">
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
</p>
)}
{/* Disclaimer */}
<div className="flex items-start gap-1.5 pt-1">
<Info className="text-muted-foreground mt-0.5 h-3 w-3 shrink-0" />
<p className="text-muted-foreground text-xs">{insight.disclaimer}</p>
</div>
</CardContent>
</Card>
);
}
+10 -2
View File
@@ -136,6 +136,15 @@ export function InsightsTabStrip({
<nav
data-slot="insights-tab-strip"
aria-label={t("insights.navAriaLabel")}
// v1.4.28 FB-D3 — `touch-action: pan-y` lets vertical swipes that
// begin on the sticky strip scroll the page through it. The
// legacy `overflow-x-auto` on the outer `<nav>` claimed the
// entire touch surface as a horizontal scroll container, so
// vertical drags that started on the pill row never reached the
// document. The horizontal pill scroll now lives on the inner
// `<div>` below; the outer `<nav>` stays sticky but no longer
// owns the gesture.
style={{ touchAction: "pan-y" }}
className={cn(
// v1.4.27 MB7 / CF-72 — relative wrapper hosts the right-edge
// fade overlay below so the user reads "there's more to
@@ -144,8 +153,7 @@ export function InsightsTabStrip({
// strip via a sibling div; it sits only on `<sm` because the
// wider viewport above already shows every pill.
"relative",
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"bg-background/95 sticky top-0 z-30 border-b py-2 backdrop-blur",
)}
>
<div className="flex items-center gap-2">
@@ -0,0 +1,51 @@
"use client";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
/**
* Layout-level mobile Coach launch FAB.
*
* v1.4.28 R3c — promoted out of `<CoachLaunchButton>` (which previously
* rendered both the FAB and the inline pill from a single component;
* each sub-page mount therefore painted a duplicate FAB into the DOM
* before `fixed` positioning collapsed them visually). Mounting the
* FAB once at the layout level keeps the affordance reachable from
* every routed Insights surface while leaving the a11y tree with a
* single button.
*
* Hidden on `lg+` viewports — desktop users reach the Coach via the
* inline `<CoachLaunchButton>` pill that each page mounts in its
* action row.
*/
export function LayoutCoachFab() {
const { t } = useTranslations();
const launch = useCoachLaunch();
if (!launch) return null;
const accessibleLabel = t("insights.heroActionAskCoach");
return (
<Button
type="button"
size="lg"
data-slot="coach-launch-fab"
onClick={() => launch.askCoach(null)}
aria-label={accessibleLabel}
title={accessibleLabel}
className={cn(
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
);
}
@@ -0,0 +1,73 @@
"use client";
import type { ReactNode } from "react";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.4.28 R3d (BK-F-M1 + BK-MED-4) — shared empty-state primitive for
* the insights sub-pages.
*
* Pre-fix, every sub-page repeated the same five-line incantation:
*
* <EmptyState icon={…} title={…} description={…} ctaSize="lg" action={…} />
* <CoachLaunchButton prefill={…} />
*
* The action slot was always a `<Button asChild><Link href="…">…</Link></Button>`
* pointing into `/measurements?add=<TYPE>` (or `/mood` / `/medications`
* for the event-driven metrics). Some sub-pages forgot to mount the
* `<CoachLaunchButton>` underneath; BK-MED-4 wanted every sub-page to
* expose a Coach launch on the empty state because the conversational
* affordance is exactly the right onboarding nudge when the user hasn't
* logged data yet.
*
* This primitive consolidates both responsibilities. Consumers pass:
*
* - the `icon` + `title` + `description` strings (already localised),
* - the primary CTA node (typically a `<Button asChild>` link),
* - the optional Coach `prefill` so a tap on "Ask the Coach" seeds
* the next turn with the metric-specific context.
*
* The Coach launch always renders (BK-MED-4). Pass `coachPrefill={null}`
* to use the default prompt; pass a string to seed a metric-specific
* onboarding question.
*/
export interface MetricEmptyStateProps {
/** Icon node (typically a `lucide-react` glyph). */
icon: ReactNode;
/** Empty-state headline (already localised). */
title: string;
/** One-line scaffold beneath the headline (already localised). */
description: string;
/** Primary CTA — usually a `<Button asChild><Link/></Button>`. */
cta: ReactNode;
/**
* Optional Coach prefill. `null` falls back to the shared default
* prompt the `<CoachLaunchButton>` ships. Pass a metric-specific
* sentence ("I haven't recorded any blood pressure yet — …") to
* seed the first Coach turn with onboarding context.
*/
coachPrefill?: string | null;
}
export function MetricEmptyState({
icon,
title,
description,
cta,
coachPrefill,
}: MetricEmptyStateProps) {
return (
<>
<EmptyState
icon={icon}
title={title}
description={description}
ctaSize="lg"
action={cta}
/>
<CoachLaunchButton prefill={coachPrefill ?? undefined} />
</>
);
}
@@ -1,15 +1,14 @@
"use client";
import dynamic from "next/dynamic";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { useTranslations } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
/**
* v1.4.25 W4c — total nightly sleep duration over time.
*
* Thin wrapper around `<HealthChart>` so the sleep sub-page gets the
* same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
* Thin wrapper around `<HealthChartDynamic>` so the sleep sub-page gets
* the same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
* surfaces. SLEEP_DURATION rows from Apple Health are per-stage (one
* row per stage per night) — the analytics API aggregates per Berlin
* day before summarising, and `HealthChart` itself relies on the
@@ -20,15 +19,11 @@ import type { ComparisonBaseline } from "@/lib/dashboard-layout";
* (v1.4.23 schema note). The chart shows the raw minutes value with a
* "min" unit suffix — the parent sub-page renders a separate
* "X h Y min" headline above the chart for the human-friendly read.
*
* v1.4.28 R3d (BK-F-M2) — the inline `dynamic()` call site was retired
* in favour of the shared `<HealthChartDynamic>` re-export so the
* `<ChartSkeleton>` + `ssr: false` configuration stays in one place.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
export interface SleepDurationChartProps {
compareBaseline?: ComparisonBaseline;
userTimezone?: string;
@@ -40,7 +35,7 @@ export function SleepDurationChart({
}: SleepDurationChartProps) {
const { t } = useTranslations();
return (
<HealthChart
<HealthChartDynamic
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("charts.sleep")}
+17 -11
View File
@@ -60,20 +60,26 @@ export function SubPageShell({
}: SubPageShellProps) {
// a11y: focus the heading on mount so a tab-strip navigation actually
// moves screen-reader focus into the sub-page body. v1.4.27 MB7 /
// CF-35 made the call opt-in via `focusOnMount` — the default-on
// version was stealing focus from soft-keyboards on mobile and
// breaking mid-flow form entries. The `scrollTo` still fires
// unconditionally; both honour `prefers-reduced-motion: reduce`
// implicitly because `scrollTo({ behavior: "auto" })` does not
// animate, and the focus call is a single discrete event.
// CF-35 made the focus call opt-in via `focusOnMount` — the
// default-on version stole focus from soft-keyboards on mobile.
//
// v1.4.28 FB-D3 — defer the `scrollTo` to the next animation frame
// so the sub-page's first paint settles before the scroll lands.
// The legacy unconditional in-effect scroll fired before the chart
// skeleton had a height; the viewport snapped to 0, then the chart
// popped in and the scroll position visibly jumped again. Wrapping
// in `requestAnimationFrame` runs the scroll after the layout pass
// so the user sees one smooth landing.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
if (typeof window !== "undefined") {
if (typeof window === "undefined") return;
const handle = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}
if (focusOnMount) {
headingRef.current?.focus({ preventScroll: true });
}
if (focusOnMount) {
headingRef.current?.focus({ preventScroll: true });
}
});
return () => window.cancelAnimationFrame(handle);
}, [focusOnMount]);
return (
+14 -2
View File
@@ -56,7 +56,12 @@ export function TrendAnnotation({
<p
data-slot="trend-annotation-empty"
data-metric={metric}
className="text-muted-foreground text-xs italic"
// v1.4.28 R3c-Insights — `line-clamp-3` on both states. The
// empty-state copy is short by construction but the row
// contract still pins the slot's height so the chart slot
// above stays aligned with the filled-state neighbour
// tiles. The empty caption never inflates the row.
className="text-muted-foreground line-clamp-3 text-xs italic"
>
{t(EMPTY_KEY[metric])}
</p>
@@ -74,7 +79,14 @@ export function TrendAnnotation({
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-foreground text-xs leading-snug">
{/* v1.4.28 R3c-Insights — `line-clamp-3` bounds caption
variance (FB-K2). A 1-sentence BP annotation paints
~3 lines wrapped; a 4-line mood annotation used to push
the cell taller, which `auto-rows-fr` propagated to
every neighbour cell. With the clamp the longest
annotation ends with an ellipsis at 3 lines and the
row stays at a single visual rhythm. */}
<p className="text-foreground line-clamp-3 text-xs leading-snug">
{stripChartTokens(annotation)}
</p>
{confidence ? (
+58 -52
View File
@@ -3,57 +3,41 @@
import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
} from "./trend-annotation";
/**
* v1.4.20 phase B3 — Trends row.
*
* Renders three small charts (BP / Weight / Mood) above a one-sentence
* AI annotation each. Recharts is ~108 KiB Brotli, so we mirror the
* `<ScatterCorrelationChart>` defer-load pattern from `/insights` for
* the two HealthChart-backed cards. The MoodChart already has its own
* fetch wired so it composes naturally.
* Trends row — three small charts (BP / Weight / Mood) above a
* one-sentence assessment each. Recharts is ~108 KiB Brotli, so the
* three charts ride the shared lazy-import boundary
* (`<HealthChartDynamicDynamic>` for BP + weight; MoodChart still resolves
* locally because no shared re-export exists yet for it).
*
* Layout:
* - `<md`: single column, full width
* - `>=md`: 3-up grid, equal column tracks
*
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the AI
* advisor payload. When a metric's annotation is null, the
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the
* Insights payload. When a metric's annotation is null, the
* `<TrendAnnotation>` empty state hints at the gap without breaking
* the row's visual rhythm.
*/
// v1.4.22 W5 reconcile (S-04) — both `dynamic()` calls used the same
// pulse-skeleton placeholder. Hoist so the loading affordance stays
// in lock-step (cleared against `feedback_charts_visual_identity.md`
// — placeholder only, no chart visual change).
const ChartSkeleton = () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
);
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false, loading: ChartSkeleton },
);
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: ChartSkeleton },
{ ssr: false, loading: () => <ChartSkeleton /> },
);
interface TrendsRowProps {
/**
* AI-authored one-sentence annotations, keyed by metric. Optional —
* Assistant-authored one-sentence annotations, keyed by metric. Optional —
* legacy advisor payloads (pre-PROMPT_VERSION 4.20.1) won't carry
* the field.
*/
@@ -96,7 +80,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
{t("insights.trendsRow.subtitle")}
</p>
</div>
{/* v1.4.22 A4 — equal-height cards. The AI annotation prose
{/* v1.4.22 A4 — equal-height cards. The annotation prose
below each chart varies in length, which used to leave the
three cards on visibly different baselines. Each card is now
a flex column with `min-h-[300px]` so the chart anchors to
@@ -110,22 +94,40 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
v1.4.27 MB7 / CF-71 — drop the unconditional `min-h-[300px]`
floor to `md:min-h-[300px]` so mobile single-column doesn't
eat dead space below a short chart; the floor stays on `md+`
to preserve the equal-height baseline across the row. */}
<div className="grid grid-cols-1 gap-4 md:auto-rows-fr md:grid-cols-3 md:items-stretch">
to preserve the equal-height baseline across the row.
v1.4.28 R3c-Insights (FB-K1/K2) — three-slot template.
`auto-rows-fr` covers every viewport, not just `md+`, so the
mobile single-column path picks up the same row contract
when the user expands the strip side-by-side via the
orientation-change. The chart slot is now wrapped in a
fixed-height shell so BP / weight / mood all paint the
series at the same vertical position; the annotation slot
clamps via `<TrendAnnotation>` so long captions can't pull
the row taller than the design constant. */}
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-3 md:items-stretch">
<div
data-slot="trends-row-card"
data-metric="bp"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<HealthChart
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
mini
userTimezone={userTimezone}
/>
{/* v1.4.28 R3c-Insights — fixed chart slot. `<HealthChartDynamic>`
mini paints its own 140 px chart band; this wrapper pins
the total chart-envelope height so the mood tile's Card
wrapper (which carries a heavier shell on a default
shadcn Card) lines up with the BP/weight tiles' lighter
shell. Both chart types ship the same data-slot now. */}
<div data-slot="trends-row-chart-slot" className="shrink-0">
<HealthChartDynamic
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="bp"
annotation={bpAnnotation}
@@ -137,14 +139,16 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
data-metric="weight"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<HealthChart
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
mini
userTimezone={userTimezone}
/>
<div data-slot="trends-row-chart-slot" className="shrink-0">
<HealthChartDynamic
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="weight"
annotation={weightAnnotation}
@@ -156,11 +160,13 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
data-metric="mood"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<MoodChart
title={t("charts.mood")}
mini
userTimezone={userTimezone}
/>
<div data-slot="trends-row-chart-slot" className="shrink-0">
<MoodChart
title={t("charts.mood")}
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="mood"
annotation={moodAnnotation}
@@ -23,9 +23,9 @@ import { queryKeys } from "@/lib/query-keys";
* C1+C2 specifically because it assumed that duplication was required,
* but the route already supports the cache-aware path.
*
* `/insights` (`<InsightAdvisorCard>`) and any other surface that mounts
* the query under the same key share the cache, so a regenerate from
* one surface refreshes the others without a second LLM call.
* Every surface that mounts the query under the same key shares the
* cache, so a regenerate from one surface refreshes the others without
* a second LLM call.
*/
export interface InsightAdvisorPayload {
insights: InsightResult;
+6 -16
View File
@@ -1,12 +1,12 @@
"use client";
import dynamic from "next/dynamic";
import { ArrowDown, ArrowRight, ArrowUp, Gauge, Minus } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import type { DataSummary } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`.
@@ -22,22 +22,12 @@ import { cn } from "@/lib/utils";
* The card stays mounted even when `summary` reports zero samples — a
* brand-new account gets the "no data yet" hint rather than a missing
* surface, matching the dashboard tile's opt-in pattern.
*
* The HealthChart mounts behind the shared `<HealthChartDynamic>`
* boundary so the lazy-import + skeleton-loading state stays in
* lock-step with the other Insights chart consumers.
*/
// Mirror the Recharts defer-load + skeleton used by `<TrendsRow>` so the
// VO2 chart-row doesn't blow up the Insights bundle.
const ChartSkeleton = () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
);
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false, loading: ChartSkeleton },
);
interface Vo2MaxChartRowProps {
/**
* Analytics summary for `MeasurementType=VO2_MAX`. Null is allowed so
@@ -205,7 +195,7 @@ export function Vo2MaxChartRow({
the dashboard VO2 tile and from the pulse chart sitting above
it on the same page. */}
{hasData ? (
<HealthChart
<HealthChartDynamic
chartKey="vo2Max"
types={["VO2_MAX"]}
title={title}
@@ -1,324 +0,0 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import {
ArrowLeft,
CheckCircle2,
Eye,
FileText,
Lightbulb,
Loader2,
Printer,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { weekISOToRange } from "@/lib/insights/week-iso";
import type { WeeklyReport } from "@/lib/ai/schema";
import { useAuth } from "@/hooks/use-auth";
/**
* v1.4.20 phase B4 — Newsletter-style printable weekly report.
*
* The page is intentionally client-side so it can read the cached AI
* advisor payload via `useInsightsAdvisorQuery`. Each section (Summary,
* What's going well, What's worth watching, Tips, Data-quality notes)
* mirrors the `weeklyReportSchema` shape one-to-one so the layout is
* deterministic — no surprise sections, no missing sections.
*
* Print export is `window.print()` for v1.4.20 (the artboard's "PDF"
* button maps to the same call). The page carries a `print:` Tailwind
* stylesheet that hides the app shell + tightens margins so an A4 /
* Letter print lands cleanly without margin clipping.
*
* The `?print=1` query forces an automatic `window.print()` call once
* the report has hydrated — used by the hero strip's banner-card
* "Export PDF" button so the user lands on a print-ready surface
* without a second click.
*/
export interface WeeklyReportViewProps {
/** ISO-week identifier the route parsed (`YYYY-Www`). */
weekISO: string;
/** Honour `?print=1` and call `window.print()` after first paint. */
autoPrint?: boolean;
}
export function WeeklyReportView({
weekISO,
autoPrint = false,
}: WeeklyReportViewProps) {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const advisor = useInsightsAdvisorQuery(isAuthenticated);
const cachedReport: WeeklyReport | null =
(advisor.payload?.insights as { weeklyReport?: WeeklyReport | null })
?.weeklyReport ?? null;
// The advisor query keys the cached payload to the freshest insight,
// not a specific week — we only show the cached report when its
// weekISO matches the route param. Cross-week mismatches resolve to
// the empty state with a Generate CTA so the user understands what's
// happening.
const matchedReport =
cachedReport && cachedReport.weekISO === weekISO ? cachedReport : null;
// Auto-print when the banner card's "Export PDF" deep-link landed
// here with `?print=1`. We fire after a short paint delay so the
// print preview surfaces the report content, not the loading state.
useEffect(() => {
if (!autoPrint) return;
if (advisor.isLoading) return;
if (!matchedReport) return;
const timer = window.setTimeout(() => {
try {
window.print();
} catch {
// Some embedded webviews disallow print(); fall through silently.
}
}, 300);
return () => window.clearTimeout(timer);
}, [autoPrint, advisor.isLoading, matchedReport]);
if (authLoading || advisor.isLoading) {
return (
<div
data-slot="weekly-report-loading"
className="flex h-64 items-center justify-center"
>
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
}
/**
* Pure presentational layer used by both the live route and the
* vitest renderer. Decoupling it from `useInsightsAdvisorQuery` /
* `useAuth` keeps the unit tests free of TanStack-Query setup.
*/
export interface WeeklyReportPresentationProps {
weekISO: string;
report: WeeklyReport | null;
}
export function WeeklyReportPresentation({
weekISO,
report,
}: WeeklyReportPresentationProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const range = weekISOToRange(weekISO);
const dateRangeLabel = range
? `${fmt.date(range.start)} — ${fmt.date(range.end)}`
: weekISO;
return (
<article
data-slot="weekly-report"
lang={locale}
className="mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 print:max-w-none print:px-0 print:py-0"
>
{/* Top bar — back link + print button. Hidden in print output. */}
<header
data-slot="weekly-report-toolbar"
className="flex flex-wrap items-center justify-between gap-2 print:hidden"
>
<Button variant="ghost" size="sm" asChild className="gap-1.5">
<Link href="/insights">
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
{t("insights.report.backToInsights")}
</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
try {
window.print();
} catch {
// No-op — older webviews disallow programmatic print().
}
}}
data-slot="weekly-report-print"
className="gap-1.5"
>
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
{t("insights.report.printAction")}
</Button>
</header>
{/* Hero — title + date range + meta. Always visible (incl. print). */}
<section
data-slot="weekly-report-hero"
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:bg-none print:p-0"
>
<span
data-slot="weekly-report-eyebrow"
className="bg-dracula-purple/15 text-dracula-purple print:text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0"
>
<FileText className="h-3 w-3" aria-hidden="true" />
{t("insights.report.eyebrow")}
</span>
<h1
data-slot="weekly-report-title"
className="text-2xl font-semibold tracking-tight sm:text-3xl"
>
{t("insights.report.title", { week: weekISO })}
</h1>
<p
data-slot="weekly-report-daterange"
className="text-muted-foreground text-sm"
>
{dateRangeLabel}
</p>
</section>
{!report ? (
<EmptyReportState weekISO={weekISO} />
) : (
<>
{/* TL;DR — Summary */}
<Section
slot="weekly-report-summary"
title={t("insights.report.sectionSummary")}
accent="text-dracula-purple"
>
<p className="text-sm leading-relaxed">
{stripChartTokens(report.summary)}
</p>
</Section>
{/* Going well */}
{report.goingWell.length > 0 && (
<Section
slot="weekly-report-going-well"
title={t("insights.report.sectionGoingWell")}
accent="text-dracula-green"
icon={<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.goingWell.map(stripChartTokens)} />
</Section>
)}
{/* Worth watching */}
{report.worthWatching.length > 0 && (
<Section
slot="weekly-report-worth-watching"
title={t("insights.report.sectionWorthWatching")}
accent="text-dracula-orange"
icon={<Eye className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.worthWatching.map(stripChartTokens)} />
</Section>
)}
{/* Tips */}
{report.tips.length > 0 && (
<Section
slot="weekly-report-tips"
title={t("insights.report.sectionTips")}
accent="text-dracula-cyan"
icon={<Lightbulb className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.tips.map(stripChartTokens)} />
</Section>
)}
{/* Data-quality notes — surfaced ONLY when the model emitted one. */}
{report.dataQualityNotes && (
<Section
slot="weekly-report-data-quality"
title={t("insights.report.sectionDataQuality")}
accent="text-muted-foreground"
>
<p className="text-muted-foreground text-sm leading-relaxed">
{stripChartTokens(report.dataQualityNotes)}
</p>
</Section>
)}
<footer
data-slot="weekly-report-footer"
className="text-muted-foreground border-border/60 border-t pt-4 text-[11px] leading-relaxed"
>
<p>
<Sparkles
className="mr-1.5 inline-block h-3 w-3 align-text-bottom"
aria-hidden="true"
/>
{t("insights.report.footerNotMedicalAdvice")}
</p>
</footer>
</>
)}
</article>
);
}
interface SectionProps {
slot: string;
title: string;
accent: string;
icon?: React.ReactNode;
children: React.ReactNode;
}
function Section({ slot, title, accent, icon, children }: SectionProps) {
return (
<section
data-slot={slot}
className="border-border/60 space-y-2 rounded-xl border p-4 sm:p-5 print:rounded-none print:border-0 print:p-0"
>
<h2
className={`flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase ${accent}`}
>
{icon}
<span>{title}</span>
</h2>
{children}
</section>
);
}
function BulletList({ items }: { items: string[] }) {
return (
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed">
{items.map((item, index) => (
<li key={`${index}-${item.slice(0, 16)}`}>{item}</li>
))}
</ul>
);
}
function EmptyReportState({ weekISO }: { weekISO: string }) {
const { t } = useTranslations();
return (
<section
data-slot="weekly-report-empty"
className="border-border/60 space-y-3 rounded-xl border border-dashed p-6 text-center"
>
<Sparkles
className="text-muted-foreground mx-auto h-6 w-6"
aria-hidden="true"
/>
<h2 className="text-base font-medium">
{t("insights.report.emptyTitle")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.report.emptyDescription", { week: weekISO })}
</p>
<div className="flex justify-center">
<Button asChild size="sm" variant="outline">
<Link href="/insights">{t("insights.report.emptyAction")}</Link>
</Button>
</div>
</section>
);
}
@@ -232,9 +232,24 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
}),
});
const json = await res.json();
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
if (!res.ok) {
throw new Error(json.error ?? "Update failed");
// v1.4.28 FB-B1 — the PUT route now returns a 409 with
// `meta.errorCode === "measurement.duplicate_timestamp"` when
// the edit collides with an existing row's
// `(type, measuredAt, source, sleepStage)` tuple. Forward the
// code alongside the message so `onError` can pick the
// localised string.
const err = new Error(json.error ?? "Update failed") as Error & {
errorCode?: string;
status?: number;
};
err.errorCode = json.meta?.errorCode;
err.status = res.status;
throw err;
}
},
onSuccess: async () => {
@@ -243,6 +258,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
setEditError(null);
},
onError: (err) => {
const errWithCode = err as Error & { errorCode?: string };
if (errWithCode.errorCode === "measurement.duplicate_timestamp") {
setEditError(t("measurements.duplicateTimestamp"));
return;
}
setEditError(
err instanceof Error ? err.message : t("measurements.saveError"),
);
+77 -139
View File
@@ -5,7 +5,7 @@
* medications. Opt-in behind Research Mode (the acknowledgment dialog
* in this directory gates entry); even when enabled the chart paints
* "Estimated level (relative)" on the y-axis without tick labels —
* research §2.3 + W19c-Backend phase report carry the rationale.
* research §2.3 + W19c-Backend round report carry the rationale.
*
* Inputs (read-only):
* - Drug id resolved from the medication name (catalog lookup via
@@ -27,17 +27,19 @@
* `glp1-pk.ts`. The chart renders a 21-day window so three weekly
* cycles are visible, anchored at "now". Mobile-first ResponsiveContainer,
* Dracula tokens that match the rest of the chart suite.
*
* v1.4.28 — the dashboard tile that consumed the compact rendering
* branch retired with FB-A2. The standalone `/medications/[id]/history`
* page is the sole mount; the chart now lifts onto the canonical
* `<MedicationDetailSection>` chrome alongside Titration / Scheduling /
* SideEffects (UI-H1). The `compact` prop + `windowHoursBefore` override
* dropped with the tile.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Activity,
AlertTriangle,
Loader2,
Syringe,
} from "lucide-react";
import { AlertTriangle, Loader2, Syringe } from "lucide-react";
import {
Area,
AreaChart,
@@ -61,6 +63,7 @@ import {
import { parseDoseMg } from "@/lib/medications/dose-string";
import type { ResearchModeStatus } from "@/lib/medications/research-mode-types";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
const CHART_COLOR = "var(--dracula-purple)";
const CHART_FILL_OPACITY = 0.18;
@@ -111,34 +114,9 @@ export interface DrugLevelChartProps {
* cost low.
*/
asOf?: Date;
/**
* v1.4.27 B1 — compact rendering for tile-internal mounts (the GLP-1
* dashboard tile carves out a mini pane for this chart). When set:
* - the outer `<section class="bg-card …">` wrapper drops so the
* parent tile owns the card surface (no card-inside-a-card).
* - the chart body shrinks (160 px instead of 240 px) to match
* mini-mode density of the other tile-internal charts.
* - the header + disclaimer collapse to a single inline caption.
*/
compact?: boolean;
/**
* v1.4.27 B1 — window length override (in hours of history before
* `asOf`). Defaults to 21 × 24 = 504 h (three weekly cycles), the
* shape the standalone surface ships. The dashboard mini-chart
* exposes 7d / 30d / 90d / All via a range strip and threads the
* mapped window length through this prop. The sample step scales
* with the window so an "All"-mode chart doesn't paint thousands of
* points: 6 h / 12 h / 24 h / 48 h for ≤ 21 d / ≤ 60 d / ≤ 180 d / >.
*/
windowHoursBefore?: number;
}
export function DrugLevelChart({
medication,
asOf,
compact = false,
windowHoursBefore,
}: DrugLevelChartProps) {
export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
const { t } = useTranslations();
// Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N
@@ -218,116 +196,82 @@ export function DrugLevelChart({
const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading));
const hasDoses = doses.length > 0;
const animationsEnabled = !prefersReducedMotion();
// Resolve the window length once, then derive a sample step that
// keeps the AreaChart inside a sensible point count even on the
// "All" branch of the dashboard's range strip.
const resolvedWindowHours = windowHoursBefore ?? WINDOW_HOURS_BEFORE;
const resolvedStepHours = pickSampleStepHours(resolvedWindowHours);
const chartBodyHeightPx = compact ? 160 : 240;
// Single-window contract — three weekly cycles with the sample step
// tuned for the AreaChart's point count. The dashboard tile that
// previously threaded a window-length override retired with FB-A2.
const stepHours = pickSampleStepHours(WINDOW_HOURS_BEFORE);
// Compact mode mounts the chart inside a host card (the GLP-1 tile)
// and skips the outer `<section>` wrapper so the user doesn't see a
// card painted inside a card. The disclaimer also collapses to a
// single muted line to keep the tile's vertical footprint tight.
// v1.4.27 MB7 / CF-66 — drop the `md:p-6` lift so the wrapper
// stays at the `p-4` density across viewports. The 6 unit padding
// on `md+` made the GLP-1 tile feel oversized relative to the
// neighbouring trend cards, which all use `p-4`. Compact mode
// retains the `space-y-2` layout without an outer card wrapper.
const wrapperClass = compact
? "space-y-2"
: "bg-card border-border rounded-xl border p-4";
const wrapperProps = {
"aria-labelledby": "drug-level-chart-title",
className: wrapperClass,
"data-slot": "drug-level-chart",
"data-compact": compact ? "true" : undefined,
} as const;
const body = (
<>
{!compact && (
<header className="mb-3 flex flex-wrap items-center gap-2">
<Activity className="text-dracula-purple h-4 w-4 shrink-0" />
<h3 id="drug-level-chart-title" className="text-sm font-semibold">
{t("medications.researchMode.chart.title")}
</h3>
{drugId && (
<span className="text-muted-foreground text-xs">
· {t(`medications.glp1.drug.${drugId}.name`)}
</span>
)}
</header>
)}
{compact && (
<h3 id="drug-level-chart-title" className="sr-only">
{t("medications.researchMode.chart.title")}
</h3>
)}
// UI-H1 — the chart mounts inside the shared
// `<MedicationDetailSection>` chrome alongside Titration / Scheduling
// / SideEffects so the `/medications/[id]/history` page reads on one
// section recipe. The drug-INN qualifier rides the headerExtras slot
// on the right edge of the header band.
const headerExtras = drugId ? (
<span className="text-muted-foreground text-xs">
{t(`medications.glp1.drug.${drugId}.name`)}
</span>
) : null;
{/* Decision tree:
1. drug not in catalog → gated-unknown-drug placeholder
2. !researchMode.enabled OR → gated placeholder + Settings CTA
versions !== aligned (the defence-in-depth rule)
3. loading → skeleton
4. no intake events → empty-state with log CTA
5. otherwise → AreaChart */}
{!drugId ? (
<GatedUnknownDrug medicationName={medication.name} />
) : !gateOpen ? (
<GatedPlaceholder
versionsAligned={versionsAligned}
knownState={researchMode}
/>
) : isLoading ? (
<div
className={
compact
? "flex h-[160px] items-center justify-center"
: "flex h-[220px] items-center justify-center"
}
data-slot="drug-level-chart-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !hasDoses ? (
<EmptyState />
) : (
<ChartBody
drug={drugId}
doses={doses}
asOf={now}
animationsEnabled={animationsEnabled}
axisLabel={t("medications.researchMode.chart.axisLabel")}
windowHoursBefore={resolvedWindowHours}
stepHours={resolvedStepHours}
heightPx={chartBodyHeightPx}
/>
)}
// Decision tree:
// 1. drug not in catalog → gated-unknown-drug placeholder
// 2. !researchMode.enabled OR → gated placeholder + Settings CTA
// versions !== aligned (the defence-in-depth rule)
// 3. loading → skeleton
// 4. no intake events → empty-state with log CTA
// 5. otherwise → AreaChart
const body = !drugId ? (
<GatedUnknownDrug medicationName={medication.name} />
) : !gateOpen ? (
<GatedPlaceholder
versionsAligned={versionsAligned}
knownState={researchMode}
/>
) : isLoading ? (
<div
className="flex h-[220px] items-center justify-center"
data-slot="drug-level-chart-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !hasDoses ? (
<EmptyState />
) : (
<ChartBody
drug={drugId}
doses={doses}
asOf={now}
animationsEnabled={animationsEnabled}
axisLabel={t("medications.researchMode.chart.axisLabel")}
stepHours={stepHours}
/>
);
return (
<MedicationDetailSection
titleId="drug-level-chart-title"
title={t("medications.researchMode.chart.title")}
headerExtras={headerExtras}
dataSlot="drug-level-chart"
>
{body}
{gateOpen && hasDoses && (
<p
className={
compact
? "text-muted-foreground text-[10px] leading-snug italic"
: "text-muted-foreground mt-2 text-xs italic"
}
className="text-muted-foreground mt-2 text-xs italic"
data-slot="drug-level-chart-disclaimer"
>
{t("medications.researchMode.chart.estimateNote")}
</p>
)}
</>
</MedicationDetailSection>
);
return compact ? <div {...wrapperProps}>{body}</div> : <section {...wrapperProps}>{body}</section>;
}
/**
* v1.4.27 B1 — pick a sample step in hours appropriate for the chosen
* window length. The default 6 h is fine at ≤ 21 days (≤ 84 samples);
* "All"-mode windows need a coarser grid so the AreaChart stays under
* a few hundred points. The chart is qualitative (research §2.3) so a
* coarse step does not change the rising/peak/fading shape.
* Pick a sample step in hours appropriate for the chosen window
* length. The default 6 h is fine at ≤ 21 days (≤ 84 samples); longer
* windows need a coarser grid so the AreaChart stays under a few
* hundred points. The chart is qualitative (research §2.3) so a coarse
* step does not change the rising/peak/fading shape.
*/
function pickSampleStepHours(windowHoursBefore: number): number {
const days = windowHoursBefore / 24;
@@ -430,9 +374,7 @@ function ChartBody({
asOf,
animationsEnabled,
axisLabel,
windowHoursBefore,
stepHours,
heightPx,
}: {
drug: Glp1DrugId;
doses: readonly DoseEvent[];
@@ -445,22 +387,18 @@ function ChartBody({
* users and SSR snapshots both surface the framing.
*/
axisLabel: string;
/** Window length in hours; the chart's x-axis domain is `[-days, 0]`. */
windowHoursBefore: number;
/** Sample step in hours; scales with the window length. */
stepHours: number;
/** Chart-area height in CSS pixels; compact mode passes 160. */
heightPx: number;
}) {
const windowDays = windowHoursBefore / HOURS_PER_DAY;
const windowDays = WINDOW_HOURS_BEFORE / HOURS_PER_DAY;
const samples = useMemo(
() =>
computeOneCompartment(drug, doses, asOf, {
windowHoursBefore,
windowHoursBefore: WINDOW_HOURS_BEFORE,
windowHoursAfter: WINDOW_HOURS_AFTER,
stepHours,
}),
[drug, doses, asOf, windowHoursBefore, stepHours],
[drug, doses, asOf, stepHours],
);
// Map samples to chart points. x-axis is "days since now" — negative
@@ -495,7 +433,7 @@ function ChartBody({
</p>
<div
className="touch-pan-y"
style={{ height: `${heightPx}px` }}
style={{ height: "240px" }}
data-slot="drug-level-chart-area"
>
<ResponsiveContainer width="100%" height="100%">
@@ -0,0 +1,79 @@
"use client";
import { type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { CardHeader, CardTitle } from "@/components/ui/card";
/**
* v1.4.28 FB-G1 — shared medication-list row header.
*
* The generic `<MedicationCard>` and the `<Glp1MedicationCard>` carry
* the same two-line row shape on the medications list page. Both rows
* route their title + dose + drug class through this primitive so the
* surface stays one consistent shape:
*
* Line 1: `{name} {dose}` — bold, `text-lg`
* Line 2: `{categoryLabel}` outline badge + optional state badges
*
* The trailing `actions` slot carries the icon-button cluster (history +
* edit) on the right of the row. State badges (without-notification,
* paused-since, inactive) ride on line 2 alongside the class label so a
* narrow viewport keeps a single inline run.
*
* The GLP-1 row previously surfaced a `<Syringe>` glyph + middle-dot
* separator on line 1 and demoted the dose to a muted inline span. This
* primitive folds it into the canonical two-line shape; the syringe icon
* and the dot separator are gone from the list row.
*/
export interface MedicationCardHeaderProps {
name: string;
dose: string;
categoryLabel: string;
stateBadges?: ReactNode;
actions?: ReactNode;
}
export function MedicationCardHeader({
name,
dose,
categoryLabel,
stateBadges,
actions,
}: MedicationCardHeaderProps) {
return (
<CardHeader className="pb-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<CardTitle className="text-lg">
{name} {dose}
</CardTitle>
{/* D-H6 — state badges (without-notification, paused-since,
inactive) used to share line 2 with the category badge
via `flex flex-wrap`. At 320 px any state badge pushed
the row to three lines and broke the FB-G1 "two-line,
no exceptions" contract for ~20 % of configured drugs.
State badges now ride their own row below the category
badge so the canonical row stays two lines on narrow
viewports. */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
</div>
{stateBadges ? (
<div
data-slot="medication-card-header-state-badges"
className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
{stateBadges}
</div>
) : null}
</div>
{actions ? (
<div className="flex items-center gap-0.5">{actions}</div>
) : null}
</div>
</CardHeader>
);
}
@@ -13,9 +13,10 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
/**
* v1.4.25 W19e — GLP-1 cadence + compliance section.
*
* Sits between `<SideEffectsSection>` (W19d) and `<IntakeHistoryList>`
* on the medication detail page. Same chrome as the W19d / W19f
* sections, composed via the shared `<MedicationDetailSection>` wrapper.
* Sits between `<SideEffectsSection>` (W19d) and `<TitrationSection>`
* on the medication detail page (v1.4.28 retired the IntakeHistoryList
* block below). Same chrome as the W19d / W19f sections, composed via
* the shared `<MedicationDetailSection>` wrapper.
*
* Three sub-sections, top-to-bottom:
*
@@ -182,7 +183,7 @@ export function SchedulingSection({
{/* 30-day timeline */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.scheduling.cadenceViz.title")}
</p>
{data.timeline.length === 0 ? (
@@ -231,7 +232,7 @@ export function SchedulingSection({
);
})}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-[10px]">
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-xs">
{(["taken", "skipped", "missed", "upcoming"] as const).map(
(s) => (
<span key={s} className="inline-flex items-center gap-1">
@@ -253,7 +254,7 @@ export function SchedulingSection({
{/* Compliance chips */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.scheduling.compliance.title")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -312,7 +313,7 @@ function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) {
title={tooltip}
aria-label={`${label}: ${value}. ${tooltip}`}
>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{label}
</p>
<p className="text-foreground text-sm font-medium">{value}</p>
@@ -44,7 +44,7 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
* timestamp + delete button + collapsed notes.
*
* v1.4.25 W21 Fix-N — wraps the shared `<MedicationDetailSection>` so
* the three Wave-4b sections share one chrome contract, and swaps the
* the three sibling sections share one chrome contract, and swaps the
* native `<select>` for the shadcn-radix `<Select>` so the picker
* matches the rest of the form library (focus rings, keyboard nav,
* theme tokens).
@@ -269,7 +269,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
{!isLoading && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.sideEffects.recentTitle")}
</p>
<ul className="space-y-1.5">
@@ -279,28 +279,48 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
)}
</Badge>
<span className="text-foreground font-medium">
{t(
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
)}
</span>
<Badge variant="secondary" className="text-[10px]">
{t(
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
)}
</Badge>
{/* v1.4.28 FB-F2 — fixed-width date slot pinned to
the right of the row meta so the date stamp keeps
its column across rows. The category badge +
entry label + severity chip share the left-most
slot; the date claims a 4ch-wide right column. */}
<div className="flex items-start gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-xs">
{t(
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
)}
</Badge>
<span className="text-foreground truncate text-sm font-medium">
{t(
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
)}
</span>
<Badge variant="secondary" className="text-xs">
{t(
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
)}
</Badge>
</div>
<p
// D-H7 — narrowed from `w-[5.5rem]` (88 px) to
// `w-14` (56 px). The longest short-date variant
// ("15. Mai") fits inside 56 px with slack;
// recovering 32 px relieves the left slot's
// wrap rhythm at 320 px so the category badge +
// entry label + severity chip stop pushing the
// free-text notes off-screen.
className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums"
data-slot="side-effect-row-date"
>
{fmt.dateShort(new Date(row.occurredAt))}
</p>
</div>
<p className="text-muted-foreground text-[11px]">
{fmt.dateShort(new Date(row.occurredAt))}
</p>
{row.notes && (
<p className="text-foreground/80 whitespace-pre-wrap break-words">
<p
className="text-foreground/80 line-clamp-2 text-sm break-words"
data-slot="side-effect-row-notes"
>
{row.notes}
</p>
)}
+12 -11
View File
@@ -10,10 +10,11 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
/**
* v1.4.25 W19f — GLP-1 titration-ladder section.
*
* Sits between `<SchedulingSection>` (W19e) and `<IntakeHistoryList>`
* on the medication detail page. Same chrome as the W19d / W19e
* sections so the Wave-4b panels read as one visual group — chrome is
* now composed via the shared `<MedicationDetailSection>` wrapper.
* Sits at the bottom of the medication detail page below the W19e
* SchedulingSection (v1.4.28 retired the IntakeHistoryList block).
* Same chrome as the W19d / W19e sections so the panels read as one
* visual group — chrome is now composed via the shared
* `<MedicationDetailSection>` wrapper.
*
* Layout:
* - Header: drug INN + "Standard ladder (EMA reference)"
@@ -103,7 +104,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
const headerExtras = data ? (
<span
className="text-muted-foreground text-[10px] uppercase tracking-wide"
className="text-muted-foreground text-xs uppercase tracking-wide"
data-slot="titration-drug-inn"
>
{data.drugInn}
@@ -132,7 +133,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
{data && !isLoading && (
<div className="space-y-3">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.titration.drugLadderHeader")}
</p>
@@ -169,7 +170,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
isCurrent ? "current" : isPast ? "past" : "upcoming"
}
>
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
<span className="text-muted-foreground text-xs uppercase tracking-wide">
{t("medications.titration.stepLabel", {
n: step.stepIndex + 1,
})}
@@ -177,13 +178,13 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
<span className="text-foreground text-sm font-medium">
{step.doseMg} {t("medications.titration.doseUnitMg")}
</span>
<span className="text-muted-foreground text-[10px]">
<span className="text-muted-foreground text-xs">
{t("medications.titration.typicalWeeksOnStep", {
weeks: step.typicalWeeks,
})}
</span>
{isCurrent && (
<span className="text-primary mt-1 text-[10px] font-medium uppercase tracking-wide">
<span className="text-primary mt-1 text-xs font-medium uppercase tracking-wide">
{t("medications.titration.youAreHere")}
</span>
)}
@@ -223,14 +224,14 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
)}
<div className="border-border/60 border-t pt-2">
<p className="text-muted-foreground text-[10px]">
<p className="text-muted-foreground text-xs">
{t("medications.titration.disclaimer")}
</p>
<a
href={data.sourceEMA}
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-[10px] underline-offset-2 hover:underline"
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("medications.titration.emaSourceCta")}
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
@@ -244,6 +244,29 @@ describe("<DrugLevelChart> — gating decision tree", () => {
expect(call.asOf).toEqual(fixedNow);
});
it("wraps the chart inside the canonical MedicationDetailSection chrome (UI-H1)", () => {
// v1.4.28 — the dashboard tile retired and the standalone
// mount on `/medications/[id]/history` lifts onto the shared
// `<MedicationDetailSection>` chrome alongside Titration /
// Scheduling / SideEffects. The aria-labelledby thread + the
// `border-border/60 rounded-md border` shell are the
// load-bearing seams.
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart"');
expect(html).toContain('aria-labelledby="drug-level-chart-title"');
expect(html).toContain("border-border/60");
expect(html).toContain("rounded-md");
// No more standalone `<header>` block on the chart — the section
// header band owns the heading scale now.
expect(html).not.toContain("text-dracula-purple h-4 w-4");
});
it("hides the y-axis tick labels (research §2.3, unit-less)", () => {
setQueryResult("research-mode", {
enabled: true,
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
/**
* v1.4.28 — narrow-viewport contract for the shared medication-list
* row primitive (D-H6).
*
* FB-G1 calls for a two-line shape: `{name} {dose}` on line 1 and the
* category badge on line 2. State badges (without-notification, paused,
* inactive) used to share line 2 via `flex flex-wrap`; on a 320 px
* viewport that pushed the row to three lines for ~20 % of configured
* drugs. State badges now ride their own row below the category badge
* so the canonical row stays two lines on narrow viewports.
*
* The tests assert the structural seams, not the visual measurement
* (no DOM measurement layer in SSR). The data-slot on the state-badges
* row plus the absence of state badges inside the category row are the
* load-bearing contracts.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(<Card>{node}</Card>);
}
describe("<MedicationCardHeader>", () => {
it("paints `{name} {dose}` on the title row", () => {
const html = render(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
/>,
);
expect(html).toContain("Ramipril 5 mg");
expect(html).toContain("Blood Pressure");
});
it("does not paint the state-badges row when no badges are supplied", () => {
const html = render(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
/>,
);
// Pin the absence so a future refactor can't paint an empty
// wrapper that eats vertical space.
expect(html).not.toContain(
'data-slot="medication-card-header-state-badges"',
);
});
it("breaks state badges onto their own row below the category badge (D-H6)", () => {
// The narrow-viewport contract: state badges live on their own
// row so the canonical row stays two lines at 320 px even when
// a drug carries one of the state badges. Pinning the seam.
const html = render(
<MedicationCardHeader
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
stateBadges={
<>
<Badge variant="secondary" className="text-xs">
Without notification
</Badge>
<Badge variant="secondary" className="text-xs">
Paused since 12.05
</Badge>
</>
}
/>,
);
expect(html).toContain(
'data-slot="medication-card-header-state-badges"',
);
// Both badges land inside the state row, not adjacent to the
// category badge. Verifying via document order: the state-badges
// wrapper must paint AFTER the category badge.
const categoryIdx = html.indexOf("Blood Pressure");
const stateRowIdx = html.indexOf(
'data-slot="medication-card-header-state-badges"',
);
expect(categoryIdx).toBeGreaterThan(-1);
expect(stateRowIdx).toBeGreaterThan(categoryIdx);
});
});
@@ -77,7 +77,11 @@ describe("<SideEffectsSection> — surface render", () => {
seedSideEffects(client, "med-1", []);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
expect(html).toContain("Side effects");
expect(html).toContain("Log side effect");
// v1.4.28 FB-F1 — the CTA dropped its qualifier so the chip stops
// overflowing the side-effects card on narrow viewports. The
// section title carries the context.
expect(html).toContain(">Log<");
expect(html).not.toContain("Log side effect");
});
it("renders the section heading and add-CTA in German", () => {
@@ -89,7 +93,8 @@ describe("<SideEffectsSection> — surface render", () => {
"de",
);
expect(html).toContain("Nebenwirkungen");
expect(html).toContain("Nebenwirkung erfassen");
expect(html).toContain(">Erfassen<");
expect(html).not.toContain("Nebenwirkung erfassen");
});
it("renders the empty-state copy when no rows are seeded", () => {
@@ -189,6 +194,41 @@ describe("<SideEffectsSection> — timeline rows", () => {
expect(html).toContain("Very severe");
});
it("narrows the date column to w-14 at narrow viewports (D-H7)", () => {
// The earlier `w-[5.5rem]` (88 px) date column overspec pushed
// the category badge + entry label + severity chip's left slot
// into a wrap-prone shape at 320 px. The narrowed `w-14` (56 px)
// fits the longest short-date variant ("15. Mai") with slack
// and recovers 32 px for the free-text notes.
const client = makeClient();
seedSideEffects(client, "med-1", [
{
id: "se-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: "2026-05-13T08:00:00.000Z",
notes: null,
},
]);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
// Class attribute order in SSR depends on JSX prop order; match
// either order so the assertion stays stable across React minor
// versions.
const dateCell = html.match(
/<p[^>]*data-slot="side-effect-row-date"[^>]*>|<p[^>]*class="[^"]*w-14[^"]*"[^>]*data-slot="side-effect-row-date"/,
);
expect(dateCell).not.toBeNull();
// Source-of-truth check via the rendered HTML — pin both the
// adoption (`w-14`) and the absence (`w-[5.5rem]`).
expect(html).toContain('data-slot="side-effect-row-date"');
const tagWithSlot = html.match(
/<p[^>]*data-slot="side-effect-row-date"[^>]*>/,
);
expect(tagWithSlot?.[0]).toContain("w-14");
expect(tagWithSlot?.[0]).not.toContain("w-[5.5rem]");
});
it("renders German entry and category labels when locale=de", () => {
const client = makeClient();
seedSideEffects(client, "med-1", [
@@ -178,8 +178,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
);
expect(html).toContain("GLP-1 injection");
// The Syringe lucide icon renders an SVG with this class.
expect(html).toMatch(/lucide-syringe/i);
// v1.4.28 FB-G1 — the Syringe glyph + middle-dot separator on the
// list row are gone. The list row reads as the canonical two-line
// shape: `{name} {dose}` on line 1, class label on line 2.
expect(html).not.toMatch(/lucide-syringe/i);
});
it("renders the default MedicationCard when treatmentClass is null/undefined (back-compat)", () => {
@@ -204,7 +206,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dose history");
});
it("shows drug name + current dose ('Mounjaro · 7.5 mg')", () => {
it("shows drug name + current dose on line 1 ('Mounjaro 7.5 mg')", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
@@ -216,9 +218,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("Mounjaro");
expect(html).toContain("7.5 mg");
// Card title joins the two with the middle-dot separator the
// glp1Headline copy uses.
expect(html).toContain("·");
// v1.4.28 FB-G1 — the GLP-1 row drops the middle-dot separator and
// surfaces `{name} {dose}` together on line 1 via the shared
// `<MedicationCardHeader>` primitive. The dot separator is gone.
expect(html).toContain("Mounjaro 7.5 mg");
});
it("shows last + next injection labels with localised weekday", () => {
@@ -245,11 +248,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toMatch(/Next:/);
});
it("renders the inline dose-history disclosure (closed by default)", () => {
// The collapsible <details> element keeps the card height matched
// to a generic card on first paint. SSR includes the element with
// no `open=""` attribute; clicking the summary toggles state via
// React's onToggle handler (not exercisable from SSR).
it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => {
// The dose-history `<details>` block was retired in v1.4.28 per
// maintainer feedback. The doseChanges payload still arrives from
// the API (iOS contract preserved), but the GLP-1 card no longer
// paints the disclosure or the summary label.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -276,16 +279,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The <details> wrapper renders with the localised summary.
expect(html).toMatch(/<details[^>]*>/);
expect(html).toContain("Dose history");
// Closed by default — no `open=""` attribute on the <details>.
const detailsTag = html.match(/<details[^>]*>/)?.[0] ?? "";
expect(detailsTag).not.toMatch(/\sopen(=""|\s|>)/);
// Dose-history rows are present in the markup even though they're
// visually hidden until expanded.
expect(html).toContain("5 mg");
expect(html).toContain("7.5 mg");
expect(html).not.toContain("Dose history");
expect(html).not.toContain("Dosis-Historie");
});
it("renders the injection-site rotation marker (last + recommended)", () => {
@@ -316,7 +311,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("Abdomen, lower left");
});
it("renders the pen-inventory line when inventory data is present", () => {
it("no longer renders the pen-inventory line (retired v1.4.28)", () => {
// The maintainer retired the entire Bestand / inventory surface
// on the GLP-1 card in v1.4.28. The iOS-consumed Glp1InventoryDTO
// slot stays in the response shape, but the web card never paints
// the inline pens-remaining line or the low-stock badge any more.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -333,14 +332,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The inventory copy template is "{pens} pens left · ~{weeks} weeks of supply".
expect(html).toContain("2 pens left");
expect(html).toContain("8 weeks of supply");
// Low-stock badge omitted when lowStock=false.
expect(html).not.toContain("pens left");
expect(html).not.toContain("Low stock");
});
it("renders the low-stock badge when inventory.lowStock is true", () => {
it("does not render the low-stock badge even when inventory.lowStock is true (retired v1.4.28)", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -357,8 +353,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
expect(html).toContain("1 pens left");
expect(html).toContain("Low stock");
expect(html).not.toContain("pens left");
expect(html).not.toContain("Low stock");
});
it("side-effect quick-log button hands off the medication object", () => {
@@ -475,6 +471,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("GLP-1-Injektion");
expect(html).toContain("Letzter Termin:");
expect(html).toContain("Bauch, unten links");
expect(html).toContain("Dosis-Historie");
// v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card.
expect(html).not.toContain("Dosis-Historie");
});
});
@@ -1,23 +1,22 @@
"use client";
import { useId, useState } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import {
Check,
ChevronDown,
Flame,
History,
Loader2,
Pencil,
SkipForward,
Stethoscope,
Syringe,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
@@ -27,16 +26,15 @@ import {
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { InventorySection } from "@/components/medications/inventory-section";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
*
* Marc directive 2026-05-14: NO chart on the medication card. The card
* stays text-rich (drug name + current dose, last/next injection,
* inline dose-history disclosure, injection-site rotation hint, pen
* inventory, side-effect quick-log). Chart-only surfaces are the
* Dashboard tile + Insights /medikamente sub-page.
* injection-site rotation hint, side-effect quick-log). v1.4.28
* retired the inline dose-history disclosure and the inventory
* surface. Chart-only surfaces are the Dashboard tile + Insights
* /medikamente sub-page.
*
* Renders inside the standard medications grid alongside generic
* medication cards; visually mirrors `medication-card.tsx` so the page
@@ -160,8 +158,6 @@ export function Glp1MedicationCard({
const { t } = useTranslations();
const fmt = useFormatters();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const doseHistoryId = useId();
const { data: compliance } = useQuery({
queryKey: ["medications", medication.id, "compliance"],
@@ -213,7 +209,6 @@ export function Glp1MedicationCard({
const rate30 = compliance?.compliance30?.rate ?? 0;
const streak = compliance?.compliance7?.streak ?? 0;
const doseChanges = details?.doseChanges ?? [];
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
@@ -223,8 +218,6 @@ export function Glp1MedicationCard({
.filter(Boolean) as InjectionSiteKey[],
);
const inventory = details?.inventory ?? null;
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
const d = new Date(medication.lastTakenAt);
@@ -248,60 +241,57 @@ export function Glp1MedicationCard({
});
}
const stateBadges = (
<>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</>
);
const headerActions = (
<>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onEdit(medication)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
</>
);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<CardHeader className="pb-2.5">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-lg">
<Syringe className="text-dracula-purple h-4 w-4 shrink-0" />
<span>{medication.name}</span>
<span className="text-muted-foreground text-sm font-normal">
· {medication.dose}
</span>
</CardTitle>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Badge variant="outline" className="text-xs">
{t("medications.treatmentClassGlp1")}
</Badge>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onEdit(medication)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={t("medications.treatmentClassGlp1")}
stateBadges={stateBadges}
actions={headerActions}
/>
<CardContent className="space-y-3.5">
{/* Injection state — last + next */}
@@ -341,83 +331,11 @@ export function Glp1MedicationCard({
</div>
)}
{/* Inventory line — only when dosesPerUnit + at least one
inventory event recorded. Low-stock gets a warning badge. */}
{inventory && inventory.pensRemaining !== null && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{t("medications.glp1Inventory", {
pens: inventory.pensRemaining,
weeks: inventory.weeksOfSupply ?? "—",
})}
</span>
{inventory.lowStock && (
<Badge variant="destructive" className="text-[10px]">
{t("medications.glp1InventoryLow")}
</Badge>
)}
</div>
)}
{/* Dose-history disclosure — collapsible row. Stays closed by
default so the card height matches a generic card on first
paint. */}
<details
className="border-border/60 rounded-md border text-xs"
open={showHistory}
onToggle={(e) =>
setShowHistory((e.target as HTMLDetailsElement).open)
}
>
<summary
className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium"
aria-controls={doseHistoryId}
aria-expanded={showHistory}
>
<span>{t("medications.glp1DoseHistory")}</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
showHistory ? "rotate-180" : ""
}`}
/>
</summary>
<div
id={doseHistoryId}
className="border-border/60 space-y-1.5 border-t px-3 py-2"
>
{doseChanges.length === 0 && (
<p className="text-muted-foreground">
{t("medications.glp1DoseHistoryEmpty")}
</p>
)}
{doseChanges
.slice()
.reverse()
.map((change) => (
<div
key={change.id}
className="flex items-baseline justify-between gap-2"
>
<span className="font-medium tabular-nums">
{change.doseValue} {change.doseUnit}
</span>
<span className="text-muted-foreground">
{t("medications.glp1DoseSince", {
date: fmt.dateShort(new Date(change.effectiveFrom)),
})}
</span>
</div>
))}
</div>
</details>
{/* v1.4.25 W19b — per-pen / per-vial inventory disclosure.
Lazy-loads the inventory list on open; otherwise stays
collapsed so the card height matches generic cards. */}
<InventorySection
medicationId={medication.id}
defaultDosesPerUnit={medication.dosesPerUnit ?? null}
/>
{/* v1.4.28 — the "Bestand" (inventory) surface retired from
the GLP-1 card: both the inline pens-remaining summary line
and the per-pen disclosure are gone. The iOS-consumed
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
{/* Compliance bars — identical to the generic card so the page
grid stays harmonious. */}
@@ -1,886 +0,0 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Loader2,
Pencil,
Trash2,
ChevronLeft,
ChevronRight,
ArrowUp,
ArrowDown,
ArrowUpDown,
MoreHorizontal,
Check,
SkipForward,
AlertTriangle,
} from "lucide-react";
import { useId, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
interface IntakeEvent {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: string;
createdAt: string;
}
interface IntakeHistoryListProps {
medicationId: string;
createOpen?: boolean;
onCreateOpenChange?: (open: boolean) => void;
}
// SOURCE_LABELS built dynamically via t() in the component
const PAGE_SIZE = 25;
function toDateTimeLocalValue(isoString: string): string {
const date = new Date(isoString);
const offset = date.getTimezoneOffset();
const local = new Date(date.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
function dateTimeLocalToISO(value: string): string {
return new Date(value).toISOString();
}
export function IntakeHistoryList({
medicationId,
createOpen,
onCreateOpenChange,
}: IntakeHistoryListProps) {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { t } = useTranslations();
const sourceLabels: Record<string, string> = {
WEB: t("medications.sourceWeb"),
API: t("medications.sourceApi"),
REMINDER: t("medications.sourceReminder"),
IMPORT: t("medications.sourceImport"),
};
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("scheduledFor");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// Edit state
const [editing, setEditing] = useState<IntakeEvent | null>(null);
const [editScheduledFor, setEditScheduledFor] = useState("");
const [editTakenAt, setEditTakenAt] = useState("");
const [editSkipped, setEditSkipped] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 MB3 — error banners reference the form inputs via
// aria-describedby so screen readers announce validation failures.
const editErrorId = useId();
const editErrorDescriptor = editError ? editErrorId : undefined;
// v1.4.27 R4 RC2 — portal targets + stable form ids so the edit and
// create sheets sticky-pin Save / Cancel via the
// `<ResponsiveSheet>` footer slot. Submit buttons keep their `<form>`
// association via the HTML `form` attribute.
const editFormId = useId();
const createFormId = useId();
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
const [createFooterEl, setCreateFooterEl] = useState<HTMLDivElement | null>(
null,
);
// Create state — controlled via props or internal
const [internalCreating, setInternalCreating] = useState(false);
const creating = createOpen ?? internalCreating;
const [createScheduledFor, setCreateScheduledFor] = useState("");
const [createTakenAt, setCreateTakenAt] = useState("");
const [createSkipped, setCreateSkipped] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const createErrorId = useId();
const createErrorDescriptor = createError ? createErrorId : undefined;
const setCreatingRaw = onCreateOpenChange ?? setInternalCreating;
const setCreating = useCallback(
(open: boolean) => {
if (open) {
const now = toDateTimeLocalValue(new Date().toISOString());
setCreateScheduledFor(now);
setCreateTakenAt(now);
setCreateSkipped(false);
setCreateError(null);
}
setCreatingRaw(open);
},
[setCreatingRaw],
);
function toggleSort(column: string) {
if (sortBy === column) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir(column === "scheduledFor" ? "desc" : "asc");
}
setPage(1);
}
const { data, isLoading } = useQuery({
queryKey: ["medications", medicationId, "intake", page, sortBy, sortDir],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
params.set("offset", String((page - 1) * PAGE_SIZE));
params.set("sortBy", sortBy);
params.set("sortDir", sortDir);
const res = await fetch(
`/api/medications/${medicationId}/intake?${params}`,
);
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data as {
events: IntakeEvent[];
meta: { total: number; limit: number; offset: number };
};
},
enabled: isAuthenticated,
});
const deleteMutation = useMutation({
mutationFn: async (eventId: string) => {
const res = await fetch(
`/api/medications/${medicationId}/intake/${eventId}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Delete failed");
},
onSuccess: () => {
void invalidateKeys(queryClient, medicationDependentKeys);
},
});
const updateMutation = useMutation({
mutationFn: async ({
eventId,
takenAt,
skipped,
scheduledFor,
}: {
eventId: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string;
}) => {
const res = await fetch(
`/api/medications/${medicationId}/intake/${eventId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ takenAt, skipped, scheduledFor }),
},
);
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Update failed");
},
onSuccess: async () => {
await invalidateKeys(queryClient, medicationDependentKeys);
setEditing(null);
setEditError(null);
},
onError: (err) => {
setEditError(
err instanceof Error ? err.message : t("medications.saveError"),
);
},
});
const createMutation = useMutation({
mutationFn: async ({
scheduledFor,
takenAt,
skipped,
}: {
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
}) => {
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scheduledFor, takenAt, skipped }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Create failed");
},
onSuccess: async () => {
await invalidateKeys(queryClient, medicationDependentKeys);
setCreating(false);
setCreateError(null);
},
onError: (err) => {
setCreateError(
err instanceof Error ? err.message : t("medications.saveError"),
);
},
});
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
function startEdit(event: IntakeEvent) {
setEditing(event);
setEditScheduledFor(toDateTimeLocalValue(event.scheduledFor));
setEditTakenAt(event.takenAt ? toDateTimeLocalValue(event.takenAt) : "");
setEditSkipped(event.skipped);
setEditError(null);
}
function closeEdit() {
if (updateMutation.isPending || deleteMutation.isPending) return;
setEditing(null);
setEditError(null);
setEditDeleteDialogOpen(false);
}
function closeCreate() {
if (createMutation.isPending) return;
setCreating(false);
setCreateError(null);
}
async function deleteEditingEvent() {
if (!editing) return;
try {
setEditError(null);
await deleteMutation.mutateAsync(editing.id);
closeEdit();
} catch {
setEditError(t("medications.deleteError"));
}
}
async function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editing) return;
const scheduledDate = new Date(editScheduledFor);
if (Number.isNaN(scheduledDate.getTime())) {
setEditError(t("medications.invalidTimestamp"));
return;
}
setEditError(null);
updateMutation.mutate({
eventId: editing.id,
scheduledFor: dateTimeLocalToISO(editScheduledFor),
takenAt:
editSkipped || !editTakenAt ? null : dateTimeLocalToISO(editTakenAt),
skipped: editSkipped,
});
}
async function submitCreate(e: React.FormEvent) {
e.preventDefault();
const scheduledDate = new Date(createScheduledFor);
if (Number.isNaN(scheduledDate.getTime())) {
setCreateError(t("medications.invalidTimestamp"));
return;
}
setCreateError(null);
createMutation.mutate({
scheduledFor: dateTimeLocalToISO(createScheduledFor),
takenAt:
createSkipped || !createTakenAt
? null
: dateTimeLocalToISO(createTakenAt),
skipped: createSkipped,
});
}
function getStatusBadge(event: IntakeEvent) {
if (event.skipped) {
return (
<Badge variant="outline" className="text-muted-foreground gap-1">
<SkipForward className="h-3 w-3" />
{t("medications.intakeStatusSkipped")}
</Badge>
);
}
if (event.takenAt) {
return (
<Badge
variant="secondary"
className="gap-1 bg-green-500/20 text-green-400"
>
<Check className="h-3 w-3" />
{t("medications.intakeStatusTaken")}
</Badge>
);
}
return (
<Badge variant="secondary" className="gap-1 bg-red-500/20 text-red-400">
<AlertTriangle className="h-3 w-3" />
{t("medications.intakeStatusMissed")}
</Badge>
);
}
if (!isAuthenticated) {
return (
<p className="text-muted-foreground text-sm">
{t("medications.loginRequiredIntakes")}
</p>
);
}
return (
<>
<div className="space-y-3">
<div className="flex items-center justify-end">
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("medications.intakeCount", { count: data.meta.total })}
</span>
)}
</div>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !data?.events?.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
{t("medications.noIntakesYet")}
</div>
) : (
<>
{/* Desktop table */}
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="scheduledFor"
label={t("medications.intakeScheduledFor")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("medications.intakeTakenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-32">
{t("medications.intakeStatus")}
</TableHead>
<SortableHead
column="source"
label={t("medications.intakeSource")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
className="w-28"
/>
<TableHead className="w-20 pr-4 text-right">
{t("medications.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.events.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-sm tabular-nums">
{formatDateTime(event.scheduledFor)}
</TableCell>
<TableCell className="text-muted-foreground text-sm tabular-nums">
{event.takenAt ? formatDateTime(event.takenAt) : "—"}
</TableCell>
<TableCell>{getStatusBadge(event)}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{sourceLabels[event.source] ?? event.source}
</Badge>
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="size-11"
onClick={() => startEdit(event)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
label={t("medications.intakeDeleteConfirm")}
description={t(
"medications.intakeDeleteDescription",
)}
onConfirm={() => deleteMutation.mutate(event.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Mobile cards */}
<div className="space-y-2 md:hidden">
{data.events.map((event) => (
<div
key={event.id}
className="bg-card border-border rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium tabular-nums">
{formatDateTime(event.scheduledFor)}
</p>
{event.takenAt && (
<p className="text-muted-foreground text-xs tabular-nums">
{t("medications.intakeTakenAt")}:{" "}
{formatDateTime(event.takenAt)}
</p>
)}
<div className="flex items-center gap-1.5">
{getStatusBadge(event)}
<Badge variant="outline" className="text-xs">
{sourceLabels[event.source] ?? event.source}
</Badge>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-11"
onClick={() => startEdit(event)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
label={t("medications.intakeDeleteConfirm")}
description={t("medications.intakeDeleteDescription")}
onConfirm={() => deleteMutation.mutate(event.id)}
/>
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{t("medications.pageInfo", { page, total: totalPages })}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="size-11"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
aria-label={t("medications.previousPage")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-11"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
aria-label={t("medications.nextPage")}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
{/* Edit Sheet */}
<ResponsiveSheet
open={!!editing}
onOpenChange={(open) => !open && closeEdit()}
title={t("medications.editIntake")}
footer={<div ref={setEditFooterEl} className="flex w-full" />}
>
{editing && (
<form
id={editFormId}
onSubmit={submitEdit}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="edit-scheduledFor">
{t("medications.intakeScheduledFor")}
</Label>
<DateTimeInput
id="edit-scheduledFor"
value={editScheduledFor}
onChange={(e) => setEditScheduledFor(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-takenAt">
{t("medications.intakeTakenAt")}
</Label>
<DateTimeInput
id="edit-takenAt"
value={editTakenAt}
onChange={(e) => setEditTakenAt(e.target.value)}
disabled={editSkipped}
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="edit-skipped"
checked={editSkipped}
onCheckedChange={(checked) => {
setEditSkipped(checked);
if (checked) setEditTakenAt("");
}}
/>
<Label htmlFor="edit-skipped">
{t("medications.intakeStatusSkipped")}
</Label>
</div>
{editError && (
<div
id={editErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
{editFooterEl
? createPortal(
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => setEditDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={closeEdit}
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={editFormId}
disabled={
updateMutation.isPending ||
deleteMutation.isPending
}
>
{updateMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</div>,
editFooterEl,
)
: null}
<AlertDialog
open={editDeleteDialogOpen}
onOpenChange={setEditDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.intakeDeleteConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.intakeDeleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={deleteEditingEvent}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
)}
</ResponsiveSheet>
{/* Create Sheet */}
<ResponsiveSheet
open={creating}
onOpenChange={(open) => !open && closeCreate()}
title={t("medications.newIntake")}
footer={<div ref={setCreateFooterEl} className="flex w-full" />}
>
<form
id={createFormId}
onSubmit={submitCreate}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="create-scheduledFor">
{t("medications.intakeScheduledFor")}
</Label>
<DateTimeInput
id="create-scheduledFor"
value={createScheduledFor}
onChange={(e) => setCreateScheduledFor(e.target.value)}
required
aria-required="true"
aria-invalid={!!createError || undefined}
aria-describedby={createErrorDescriptor}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-takenAt">
{t("medications.intakeTakenAt")}
</Label>
<DateTimeInput
id="create-takenAt"
value={createTakenAt}
onChange={(e) => setCreateTakenAt(e.target.value)}
disabled={createSkipped}
aria-invalid={!!createError || undefined}
aria-describedby={createErrorDescriptor}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="create-skipped"
checked={createSkipped}
onCheckedChange={(checked) => {
setCreateSkipped(checked);
if (checked) setCreateTakenAt("");
}}
/>
<Label htmlFor="create-skipped">
{t("medications.intakeStatusSkipped")}
</Label>
</div>
{createError && (
<div
id={createErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{createError}
</div>
)}
</form>
{createFooterEl
? createPortal(
<div className="flex w-full items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={closeCreate}
disabled={createMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={createFormId}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>,
createFooterEl,
)
: null}
</ResponsiveSheet>
</>
);
}
function SortableHead({
column,
label,
currentSort,
currentDir,
onSort,
className,
}: {
column: string;
label: string;
currentSort: string;
currentDir: "asc" | "desc";
onSort: (col: string) => void;
className?: string;
}) {
const isActive = currentSort === column;
return (
<TableHead className={className}>
<button
type="button"
onClick={() => onSort(column)}
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
{label}
{isActive ? (
currentDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)
) : (
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
</TableHead>
);
}
function DeleteButton({
label,
description,
onConfirm,
}: {
label: string;
description: string;
onConfirm: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive size-11"
aria-label={label}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{label}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,549 +0,0 @@
"use client";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MedicationInventoryState } from "@/generated/prisma/client";
import {
DEFAULT_IN_USE_WINDOW_DAYS,
daysRemainingInUse,
} from "@/lib/medications/inventory/state-machine";
/**
* v1.4.25 W19b — Inventory disclosure for the GLP-1 medication card.
*
* Sits inline in the GLP-1 card as a collapsible section. Shows the
* active and recently-used pens, surfaces the 30-day in-use clock per
* pen, and offers an "Add new pen" dialog for `purchasedAt` +
* `printedExpiry` + `dosesTotal`. Per-pen actions are
* mark-as-in-use (manual override when an intake didn't auto-flip
* the state) and mark-as-used-up (terminal override when a pen is
* physically discarded).
*
* The UI deliberately reuses existing primitives — Card, Dialog,
* Input, Badge, Button — no new shadcn block.
*
* v1.4.25 W21 Fix-N
* - The local `InventoryState` string-union has been dropped in
* favour of the canonical `MedicationInventoryState` enum from
* the Prisma client.
* - The reimplemented client-side `daysRemainingInUse` has been
* replaced by the widened pure helper from the inventory
* state-machine module; one rule for the deadline math.
*/
interface InventoryItem {
id: string;
state: MedicationInventoryState;
dosesTotal: number;
dosesRemaining: number;
firstUseAt: string | null;
expiresAt: string | null;
printedExpiry: string | null;
purchasedAt: string | null;
notes: string | null;
createdAt: string;
}
interface InventorySectionProps {
medicationId: string;
/** Doses per pen — passed in so the dialog can pre-fill the input
* with the knowledge-layer value (e.g. 4 for a Mounjaro KwikPen). */
defaultDosesPerUnit: number | null;
}
export function InventorySection({
medicationId,
defaultDosesPerUnit,
}: InventorySectionProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
// v1.4.27 B7 / L3 — explicit aria-controls between summary and disclosed
// panel for parity with the W8e Provenance accordion.
const panelId = useId();
const pastPanelId = useId();
// v1.4.27 MB3 — link the add-pen error message back to the doses-total
// input via aria-describedby so screen readers announce the failure
// when the form rejects an out-of-range value.
const formErrorId = useId();
// v1.4.27 R4 RC2 — portal target + stable form id wire the sticky
// Save / Cancel row into the `<ResponsiveSheet>` footer slot.
const formId = useId();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [dosesTotal, setDosesTotal] = useState<string>(
defaultDosesPerUnit ? String(defaultDosesPerUnit) : "",
);
const [printedExpiry, setPrintedExpiry] = useState<string>("");
const [purchasedAt, setPurchasedAt] = useState<string>("");
const [notes, setNotes] = useState<string>("");
const [formError, setFormError] = useState<string | null>(null);
const listKey = ["medications", medicationId, "inventory"] as const;
const { data, isLoading } = useQuery({
queryKey: listKey,
queryFn: async () => {
const res = await fetch(
`/api/medications/${medicationId}/inventory`,
);
if (!res.ok) return null;
const json = (await res.json()) as {
data: { items: InventoryItem[]; meta: { total: number } };
};
return json.data;
},
staleTime: 60 * 1000,
enabled: open,
});
const createMutation = useMutation({
mutationFn: async (payload: {
dosesTotal: number;
printedExpiry: string | null;
purchasedAt: string | null;
notes: string | null;
}) => {
const res = await fetch(
`/api/medications/${medicationId}/inventory`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(body.error ?? "Failed to add pen");
}
return (await res.json()).data as InventoryItem;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const patchMutation = useMutation({
mutationFn: async (vars: {
itemId: string;
body: Record<string, unknown>;
}) => {
const res = await fetch(
`/api/medications/${medicationId}/inventory/${vars.itemId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(vars.body),
},
);
if (!res.ok) throw new Error("Failed to update pen");
return (await res.json()).data as InventoryItem;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const deleteMutation = useMutation({
mutationFn: async (itemId: string) => {
const res = await fetch(
`/api/medications/${medicationId}/inventory/${itemId}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Failed to delete pen");
return true;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
function resetAddForm() {
setDosesTotal(defaultDosesPerUnit ? String(defaultDosesPerUnit) : "");
setPrintedExpiry("");
setPurchasedAt("");
setNotes("");
setFormError(null);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setFormError(null);
const dt = Number(dosesTotal);
if (!Number.isInteger(dt) || dt < 1 || dt > 100) {
setFormError(t("medications.inventory.errorDosesTotal"));
return;
}
createMutation.mutate(
{
dosesTotal: dt,
printedExpiry: printedExpiry ? new Date(printedExpiry).toISOString() : null,
purchasedAt: purchasedAt ? new Date(purchasedAt).toISOString() : null,
notes: notes.trim() ? notes.trim() : null,
},
{
onSuccess: () => {
resetAddForm();
setAddOpen(false);
},
onError: (err) => {
setFormError(err instanceof Error ? err.message : String(err));
},
},
);
}
const items = data?.items ?? [];
const liveItems = items.filter(
(i) => i.state === "ACTIVE" || i.state === "IN_USE",
);
const pastItems = items.filter(
(i) => i.state === "EXPIRED" || i.state === "USED_UP",
);
// Snapshot "now" once per render. The disclosure is opt-in (the
// user clicks to expand), so re-rendering on focus events should
// not cause the "days remaining" labels to flicker between values.
// eslint-disable-next-line react-hooks/purity -- intentional one-shot snapshot
const nowMs = useMemo(() => Date.now(), []);
function stateLabel(item: InventoryItem): string {
if (item.state === "ACTIVE") {
return t("medications.inventory.state.active");
}
if (item.state === "IN_USE") {
const d = daysRemainingInUse(
{ firstUseAt: item.firstUseAt },
nowMs,
DEFAULT_IN_USE_WINDOW_DAYS,
);
return t("medications.inventory.state.inUse", { days: d ?? 0 });
}
if (item.state === "EXPIRED") {
return t("medications.inventory.state.expired");
}
return t("medications.inventory.state.usedUp");
}
function badgeVariant(
state: MedicationInventoryState,
): "default" | "outline" | "secondary" | "destructive" {
if (state === "EXPIRED") return "destructive";
if (state === "USED_UP") return "secondary";
if (state === "IN_USE") return "default";
return "outline";
}
return (
<>
<details
className="border-border/60 rounded-md border text-xs"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
>
<summary
className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium"
aria-controls={panelId}
aria-expanded={open}
>
<span>
{t("medications.inventory.title")}
{liveItems.length > 0 && (
<span className="text-muted-foreground ml-1.5 font-normal">
({liveItems.length})
</span>
)}
</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</summary>
<div
id={panelId}
className="border-border/60 space-y-3 border-t px-3 py-2.5"
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<span>{t("medications.inventory.loading")}</span>
</div>
)}
{!isLoading && liveItems.length === 0 && pastItems.length === 0 && (
<p className="text-muted-foreground">
{t("medications.inventory.empty")}
</p>
)}
{liveItems.length > 0 && (
<ul className="space-y-2">
{liveItems.map((item) => (
<li
key={item.id}
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
>
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={badgeVariant(item.state)}
className="text-[10px]"
>
{stateLabel(item)}
</Badge>
<span className="text-foreground/85 font-medium tabular-nums">
{item.dosesRemaining}/{item.dosesTotal}{" "}
{t("medications.inventory.dosesLabel")}
</span>
</div>
{item.printedExpiry && (
<p className="text-muted-foreground">
{t("medications.inventory.printedExpiry", {
date: fmt.dateShort(new Date(item.printedExpiry)),
})}
</p>
)}
{item.purchasedAt && (
<p className="text-muted-foreground">
{t("medications.inventory.purchasedAt", {
date: fmt.dateShort(new Date(item.purchasedAt)),
})}
</p>
)}
{item.notes && (
<p className="text-muted-foreground italic">
{item.notes}
</p>
)}
</div>
<div className="flex shrink-0 flex-col gap-1">
{item.state === "ACTIVE" && (
<Button
variant="outline"
className="min-h-11 px-3 text-xs"
onClick={() =>
patchMutation.mutate({
itemId: item.id,
body: { markAsFirstUseAt: new Date().toISOString() },
})
}
disabled={patchMutation.isPending}
>
{t("medications.inventory.markAsInUse")}
</Button>
)}
{(item.state === "ACTIVE" || item.state === "IN_USE") && (
<Button
variant="ghost"
className="min-h-11 px-3 text-xs"
onClick={() =>
patchMutation.mutate({
itemId: item.id,
body: { markAsUsedUp: true },
})
}
disabled={patchMutation.isPending}
>
{t("medications.inventory.markAsUsedUp")}
</Button>
)}
</div>
</li>
))}
</ul>
)}
{pastItems.length > 0 && (
<details className="border-border/40 rounded-md border">
<summary
className="text-muted-foreground cursor-pointer list-none px-2.5 py-1.5 font-medium"
aria-controls={pastPanelId}
>
{t("medications.inventory.pastTitle", {
count: pastItems.length,
})}
</summary>
<ul
id={pastPanelId}
className="border-border/40 space-y-1 border-t px-2.5 py-2"
>
{pastItems.map((item) => (
<li
key={item.id}
className="flex items-center justify-between gap-2"
>
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={badgeVariant(item.state)}
className="text-[10px]"
>
{stateLabel(item)}
</Badge>
<span className="text-muted-foreground tabular-nums">
{item.dosesRemaining}/{item.dosesTotal}
</span>
{item.firstUseAt && (
<span className="text-muted-foreground">
·{" "}
{fmt.dateShort(new Date(item.firstUseAt))}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
aria-label={t("medications.inventory.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</details>
)}
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => setAddOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("medications.inventory.addPen")}
</Button>
</div>
</details>
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("medications.inventory.addPenTitle")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="inv-doses-total">
{t("medications.inventory.fieldDosesTotal")}
</Label>
<Input
id="inv-doses-total"
type="number"
inputMode="numeric"
enterKeyHint="next"
min={1}
max={100}
step={1}
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
required
aria-required="true"
aria-invalid={!!formError || undefined}
aria-describedby={formError ? formErrorId : undefined}
/>
<p className="text-muted-foreground text-xs">
{t("medications.inventory.fieldDosesTotalHelp")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="inv-printed-expiry">
{t("medications.inventory.fieldPrintedExpiry")}
</Label>
<Input
id="inv-printed-expiry"
type="date"
enterKeyHint="next"
value={printedExpiry}
onChange={(e) => setPrintedExpiry(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="inv-purchased-at">
{t("medications.inventory.fieldPurchasedAt")}
</Label>
<Input
id="inv-purchased-at"
type="date"
enterKeyHint="next"
value={purchasedAt}
onChange={(e) => setPurchasedAt(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="inv-notes">
{t("medications.inventory.fieldNotes")}
</Label>
<textarea
id="inv-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={200}
rows={2}
enterKeyHint="done"
autoCapitalize="sentences"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{formError && (
<p
id={formErrorId}
role="alert"
aria-live="polite"
className="text-destructive text-sm"
>
{formError}
</p>
)}
</form>
{footerEl
? createPortal(
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
resetAddForm();
setAddOpen(false);
}}
>
{t("medications.inventory.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("medications.inventory.save")}
</Button>
</div>,
footerEl,
)
: null}
</ResponsiveSheet>
</>
);
}
+55 -52
View File
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
@@ -367,59 +368,61 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
return formatDateTime(value);
}
const stateBadges = (
<>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</>
);
const headerActions = (
<>
{/* Phase A5: bumped from `h-8 w-8` (32px) to `min-h-11
min-w-11` (44px) so these meet the WCAG 2.5.5 minimum
tap-target on mobile without the icon glyph itself
changing size. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onEdit(medication)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
</>
);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<CardHeader className="pb-2.5">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-lg">{medication.name}</CardTitle>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<span>{medication.dose}</span>
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
{!medication.notificationsEnabled && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!medication.active && (
<Badge variant="secondary" className="text-xs">
{medication.pausedAt
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
: t("medications.inactive")}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-0.5">
{/* Phase A5: bumped from `h-8 w-8` (32px) to `min-h-11
min-w-11` (44px) so these meet the WCAG 2.5.5 minimum
tap-target on mobile without the icon glyph itself
changing size. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onEdit(medication)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
/>
<CardContent className="space-y-3.5">
{/* Status, last & next intake info */}
@@ -3,16 +3,24 @@
import { type ReactNode } from "react";
/**
* v1.4.25 W21 Fix-N — section wrapper for the three medication-detail
* panels (Titration, Scheduling, SideEffects). The four wave-4b
* sections previously hand-rolled the same chrome — border + header
* row + dotted divider + body padding — and drifted on padding (px-3
* vs px-3 py-2.5 vs px-3 py-2), border opacity (border-border/60 vs
* /70), and aria wiring (aria-labelledby was inconsistent).
* v1.4.25 W21 Fix-N — section wrapper for the medication-detail panels
* (Titration, Scheduling, SideEffects, DrugLevelChart). The earlier
* sections hand-rolled the same chrome — border + header row + dotted
* divider + body padding — and drifted on padding, border opacity, and
* aria wiring.
*
* This wrapper locks the contract:
* v1.4.28 FB-F3 / F4 — heading scale collapsed to one shape across the
* `/medications/[id]` surface so the page reads as one consistent
* document. Title classes now match the settings symmetry sweep:
* `text-base font-semibold leading-6 tracking-tight`. Body band keeps
* its `text-xs` density; one heading scale (`text-base font-semibold`),
* one body scale (`text-sm`), one micro scale (`text-xs`) on the page.
*
* Contract:
* - `border-border/60 rounded-md border` chrome
* - `px-3 py-2.5` header row with `text-foreground/85 text-sm font-medium` title
* - `px-3 py-2.5` header row with
* `text-foreground text-base font-semibold leading-6 tracking-tight`
* title
* - `border-border/60 border-t px-3 py-3 text-xs` body band
* - `aria-labelledby` wired to a stable per-section id derived from `titleId`
*
@@ -57,7 +65,7 @@ export function MedicationDetailSection({
<header className="flex items-center justify-between gap-2 px-3 py-2.5">
<h2
id={titleId}
className="text-foreground/85 text-sm font-medium"
className="text-foreground text-base font-semibold leading-6 tracking-tight"
>
{title}
</h2>
@@ -0,0 +1,52 @@
"use client";
import { useReportWebVitals } from "next/web-vitals";
/**
* v1.4.28 R3d — client-side mount that pipes every Web Vitals
* measurement to `/api/internal/web-vitals`.
*
* Next 16 ships `useReportWebVitals` as the supported hook for
* capturing CLS / FCP / FID / LCP / TTFB / INP from the live
* navigation. The hook fires once per metric per page load, so even a
* heavy navigation produces at most six beacons.
*
* Delivery transport: `navigator.sendBeacon` when available (queues
* the POST without blocking the page lifecycle, survives the
* `pagehide` event) with a `fetch({ keepalive: true })` fallback.
* The route accepts the payload best-effort and returns 200 even on
* malformed bodies — beacons must never delay or fail the navigation.
*/
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
delta: metric.delta,
rating: metric.rating,
navigationType: metric.navigationType,
});
const url = "/api/internal/web-vitals";
if (
typeof navigator !== "undefined" &&
typeof navigator.sendBeacon === "function"
) {
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
return;
}
// Fallback for non-`sendBeacon` browsers — `keepalive` keeps the
// request alive past navigation. We deliberately ignore the
// response (it's a beacon, not a transaction).
void fetch(url, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(() => undefined);
});
return null;
}
@@ -6,8 +6,8 @@ import { TargetCoachButton } from "../target-coach-button";
/**
* v1.4.25 W3e — gate test for the per-card Coach CTA. The button
* MUST disappear when `aiEnabled` is false (Marc directive: no
* broken-button state for users with no AI provider configured).
* MUST disappear when `aiEnabled` is false (the maintainer's rule:
* no broken-button state for users with no provider configured).
*/
function render(props: Parameters<typeof TargetCoachButton>[0]) {
@@ -28,7 +28,32 @@ describe("<TargetCoachButton>", () => {
it("renders the button when aiEnabled is true", () => {
const html = render({ ...baseProps, aiEnabled: true });
expect(html).toContain('data-slot="target-coach-cta"');
expect(html).toContain("Ask Coach");
// v1.4.28 FB-L1 — the affordance is now icon-only; the label
// survives as an aria-label + title so screen readers + tooltips
// still surface "Ask Coach" / "Coach fragen".
expect(html).toContain('aria-label="Ask Coach"');
expect(html).toContain('title="Ask Coach"');
// No visible text label — only the Sparkles glyph.
expect(html).not.toMatch(/>Ask Coach</);
// UI-H2 — the Coach-launch glyph is one Sparkles vocabulary
// across hero strip, inline pill, layout FAB and per-card icon.
expect(html).toMatch(/lucide-sparkles/i);
expect(html).not.toMatch(/lucide-message-circle/i);
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The icon variant alone ships `size-10` (40 px); the per-card
// Coach button rides `min-h-11 min-w-11` on top so the hit
// surface matches the medication-history and Coach drawer
// buttons. Pinning the class so a future refactor can't drop
// back to the 40 px shadcn default.
const html = render({ ...baseProps, aiEnabled: true });
const trigger = html.match(
/<button[^>]*data-slot="target-coach-cta"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("renders NOTHING when aiEnabled is false (no broken-button state)", () => {
+9 -10
View File
@@ -656,20 +656,19 @@ export function TargetCard({
</div>
)}
{/* Row 6: footer. Mobile-first reflow:
- default (<640px): stack vertically; the Coach CTA
spans full width so the touch target is generous, the
source link sits beneath it right-aligned.
- sm+ (≥640px): horizontal row, Coach left + source right.
`mt-auto` pins to card bottom so grid cells align. */}
<div className="mt-auto flex flex-col items-stretch gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
{/* Row 6: footer. v1.4.28 FB-L1 — the Coach CTA is now an
icon-only affordance (chat-bubble glyph + aria-label), so
the row stays horizontal at every breakpoint: icon left,
source link right. `mt-auto` pins to card bottom so grid
cells align. */}
<div className="mt-auto flex flex-row items-center justify-between gap-3 pt-1">
{aiEnabled && (
<TargetCoachButton
prefill={coachPrefill}
sources={coachSources}
onAskCoach={onAskCoach}
aiEnabled={aiEnabled}
className="w-full justify-center sm:-ml-2 sm:w-auto sm:justify-start"
className="-ml-2"
/>
)}
{sourceLink ? (
@@ -677,13 +676,13 @@ export function TargetCard({
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 self-end text-xs sm:ml-auto sm:self-auto"
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 text-xs"
>
<span>{target.source}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground self-end text-xs sm:ml-auto sm:self-auto">
<span className="text-muted-foreground ml-auto text-xs">
{target.source}
</span>
)}
+24 -5
View File
@@ -3,6 +3,7 @@
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachScope, CoachScopeSource } from "@/lib/ai/coach/types";
@@ -17,6 +18,17 @@ import type { CoachScope, CoachScopeSource } from "@/lib/ai/coach/types";
* On click the button fires `onAskCoach({ prefill, scope })`. The
* parent owns the `CoachDrawer` state; this button doesn't know about
* the drawer at all.
*
* v1.4.28 R3c FB-L1 — collapsed to an icon-only affordance. The card
* footer was carrying a labelled pill ("Coach fragen"); on a card with
* a status pill, range bar, consistency strip, headline number, edit
* cog and a source link, the labelled pill dominated the visual
* hierarchy. Dropping the label retains the affordance via the
* Sparkles glyph + `aria-label` while shrinking the optical weight
* to a per-card icon button. UI-H2 — the glyph is the same Sparkles
* the hero strip, the inline pill and the layout-level FAB use, so
* the per-card Coach launch reads on one icon vocabulary across the
* app.
*/
export interface TargetCoachButtonProps {
/**
@@ -33,7 +45,7 @@ export interface TargetCoachButtonProps {
onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void;
/**
* Renders ONLY when this is true. The parent gates on
* `chainData?.activeProvider != null` so a user with no AI provider
* `chainData?.activeProvider != null` so a user with no provider
* configured never sees a button that opens an empty drawer.
*/
aiEnabled: boolean;
@@ -51,11 +63,13 @@ export function TargetCoachButton({
if (!aiEnabled) return null;
const accessibleLabel = t("targets.coach.cta");
return (
<Button
type="button"
variant="ghost"
size="sm"
size="icon"
onClick={() =>
onAskCoach({
prefill,
@@ -66,10 +80,15 @@ export function TargetCoachButton({
})
}
data-slot="target-coach-cta"
className={className}
aria-label={accessibleLabel}
title={accessibleLabel}
// Lift the icon button to the 44 px WCAG 2.5.5 floor. The
// shadcn `icon` variant ships `size-10` (40 px); the project
// floor is 44 px (matches the medication-history button and the
// Coach drawer cluster). Glyph stays `size-4`.
className={cn("min-h-11 min-w-11", className)}
>
<Sparkles className="size-3.5" aria-hidden="true" />
<span>{t("targets.coach.cta")}</span>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
);
}