mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
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:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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 /> },
|
||||
);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
-179
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user