chore(release): v1.4.25

Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-14 23:49:32 +02:00
1 parent de0689e419
commit 17b8d8d2c2
528 files changed
+104033 -6524

No files matched your search

@@ -107,4 +107,34 @@ describe("CoachFeedbackSection", () => {
expect(html).toContain("Failed to load Coach feedback summary");
expect(html).toContain('role="alert"');
});
// v1.4.25 W8 regression — Marc reported a layout shift on
// /admin/coach-feedback. Root cause was the title only rendering in the
// empty + data branches, so the section grew taller (loading → data)
// and snapped down. Lock the heading + outer-card structure across
// every render state so the section maintains a constant top-of-card
// baseline.
it("keeps the section heading rendered across loading / error / empty states", () => {
for (const queryState of [
{ isLoading: true, isError: false, data: null },
{ isLoading: false, isError: true, data: null },
{
isLoading: false,
isError: false,
data: {
generatedAt: "2026-05-10T04:00:00Z",
windowDays: 30,
coachBuckets: [],
},
},
]) {
useQueryMock.mockReturnValue(queryState);
const html = render();
// Sparkles icon + h2 title rendered in every state ⇒ constant
// header height ⇒ no layout shift when the query resolves.
expect(html).toContain("Coach Feedback");
expect(html).toContain("bg-card");
expect(html).toContain("rounded-xl");
}
});
});
@@ -0,0 +1,258 @@
import { describe, it, expect } from "vitest";
import {
auditLogCsvHeaderLabels,
buildAuditLogCsvRecords,
type AuditCsvEntry,
type AuditCsvLabels,
} from "../_shared";
import { toCSV } from "@/lib/export";
/**
* v1.4.25 W8b — Login-Übersicht CSV export contract.
*
* The previous CSV emitted snake_case English headers and the raw UTC
* `createdAt` string, which Excel/LibreOffice misread once the `Z`
* suffix was stripped. This test pins:
*
* - The column order Marc requested:
* `timestamp → user → IP → location → provider → outcome` (email
* is absent from the audit-log API so it's intentionally skipped)
* followed by `action` + `details` for triage completeness.
* - The header row uses the translated labels from the active
* locale, not the structural keys.
* - The timestamp formatter is honoured (production passes
* `formatInUserTz` with `iso-with-offset`).
* - CSV special characters in `details` are RFC 4180-escaped.
*/
const LABELS_EN: AuditCsvLabels = {
timestamp: "Time",
user: "Users",
ip: "IP",
location: "Location",
provider: "Provider",
outcome: "Outcome",
action: "Action",
details: "Details",
outcomeFailed: "Failed",
outcomeSuccess: "Success",
unknownUser: "Unknown",
providerLabels: {
password: "Password",
passkey: "Passkey",
api_token: "API token",
withings: "Withings",
unknown: "Unknown",
},
};
const LABELS_DE: AuditCsvLabels = {
...LABELS_EN,
timestamp: "Zeitpunkt",
user: "Benutzer",
ip: "IP",
location: "Standort",
provider: "Anbieter",
outcome: "Ergebnis",
action: "Aktion",
details: "Details",
outcomeFailed: "Fehlgeschlagen",
outcomeSuccess: "Erfolgreich",
unknownUser: "Unbekannt",
providerLabels: {
password: "Passwort",
passkey: "Passkey",
api_token: "API-Token",
withings: "Withings",
unknown: "Unbekannt",
},
};
function deterministicFormatter(iso: string): string {
// Strip milliseconds, swap `Z` for `+00:00` so the test asserts the
// ISO-with-offset shape without being coupled to a specific zone.
return iso.replace(/\.\d{3}Z$/, "+00:00").replace(/Z$/, "+00:00");
}
describe("buildAuditLogCsvRecords", () => {
it("emits Marc's column order: timestamp → user → ip → location → provider → outcome (+ action, details)", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: "203.0.113.7",
location: "Berlin, DE",
details: null,
user: { id: "u1", username: "marc" },
},
];
const [record] = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
// Object.keys preserves insertion order in V8 / every modern JS engine
// — the column order is therefore guaranteed by `Object.keys(record)`.
expect(Object.keys(record)).toEqual([
"timestamp",
"user",
"ip",
"location",
"provider",
"outcome",
"action",
"details",
]);
});
it("maps the action to a coarse provider label using the i18n labels", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: null,
location: null,
details: null,
user: { id: "u1", username: "marc" },
},
{
createdAt: "2026-05-11T09:06:00.000Z",
action: "auth.bearer.success",
ipAddress: null,
location: null,
details: null,
user: { id: "u2", username: "ios" },
},
{
createdAt: "2026-05-11T09:07:00.000Z",
action: "auth.login.failed",
ipAddress: null,
location: null,
details: null,
user: null,
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
expect(records[0].provider).toBe("Passkey");
expect(records[1].provider).toBe("API token");
expect(records[2].provider).toBe("Password");
// Failed login records the failed outcome.
expect(records[0].outcome).toBe("Success");
expect(records[2].outcome).toBe("Failed");
// No user → unknown placeholder.
expect(records[2].user).toBe("Unknown");
});
it("uses the German label set when the active locale is German", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: null,
location: null,
details: null,
user: null,
},
];
const [record] = buildAuditLogCsvRecords(
entries,
LABELS_DE,
deterministicFormatter,
);
expect(record.provider).toBe("Passkey");
expect(record.outcome).toBe("Erfolgreich");
expect(record.user).toBe("Unbekannt");
});
it("applies the injected timestamp formatter (production uses formatInUserTz)", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.password",
ipAddress: null,
location: null,
details: null,
user: null,
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
(iso) => `formatted:${iso}`,
);
expect(records[0].timestamp).toBe("formatted:2026-05-11T09:05:00.000Z");
});
});
describe("auditLogCsvHeaderLabels", () => {
it("returns translated headers keyed by the record-key contract", () => {
const headers = auditLogCsvHeaderLabels(LABELS_EN);
expect(headers).toEqual({
timestamp: "Time",
user: "Users",
ip: "IP",
location: "Location",
provider: "Provider",
outcome: "Outcome",
action: "Action",
details: "Details",
});
});
});
describe("toCSV(records, headerLabels) — audit-log integration", () => {
it("writes a translated header row + correct column order", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: "203.0.113.7",
location: "Berlin, DE",
details: null,
user: { id: "u1", username: "marc" },
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_DE,
deterministicFormatter,
);
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
const lines = csv.split("\n");
expect(lines[0]).toBe(
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
);
expect(lines[1]).toBe(
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,',
);
});
it("escapes commas, quotes, and newlines in the details column", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.failed",
ipAddress: null,
location: null,
// Three RFC 4180 escape triggers in one value: a comma, a
// double quote, and a newline.
details: 'reason="invalid_password", attempts: 3\nfollowup: lock',
user: null,
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_EN));
// The cell is wrapped in double quotes and embedded `"` is doubled.
expect(csv).toContain(
'"reason=""invalid_password"", attempts: 3\nfollowup: lock"',
);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W8b — `/admin/login-overview` no longer renders a Collapse
* / Expand toggle. The toggle was a leftover from the v1.4 shared
* admin page where 13 sections lived on one route; collapsing the
* only card on the dedicated login-overview route hid the entire
* surface. Visiting the page is itself the opt-in.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/login-overview",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
entries: [],
meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 },
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "ADMIN",
timezone: "Europe/Berlin",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { LoginOverviewSection } from "../login-overview-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LoginOverviewSection />
</I18nProvider>,
);
}
describe("LoginOverviewSection — no collapse button", () => {
it("does not render a Collapse / Expand button (English)", () => {
const html = render("en");
expect(html).not.toMatch(/\bCollapse\b/);
expect(html).not.toMatch(/\bExpand\b/);
});
it("does not render an Einklappen / Ausklappen button (German)", () => {
const html = render("de");
expect(html).not.toMatch(/\bEinklappen\b/);
expect(html).not.toMatch(/\bAusklappen\b/);
});
it("never renders an `aria-expanded` button on the section header", () => {
const html = render();
// The toggle previously surfaced as <button aria-expanded={…}>
// immediately after the ScrollText icon + title pair. Radix
// Select triggers below carry `aria-expanded` legitimately, so
// we scope the assertion to the first 800 chars of the markup
// (the heading slice) instead of the entire SSR output.
const heading = html.slice(0, 800);
expect(heading).not.toMatch(/aria-expanded=/);
});
it("paints the section body unconditionally (no `expanded` gate)", () => {
// The filter pills used to live inside an `{expanded && (…)}`
// wrapper. With the toggle gone the pills must always be in the
// SSR output.
const html = render("en");
expect(html).toContain("All auth events");
expect(html).toContain("Failed only");
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { iconForAuthProvider, providerForAction } from "../_shared";
/**
* v1.4.25 W8b — Provider column for the admin Login-Übersicht.
*
* The audit-log entries don't carry a dedicated "provider" field — the
* action name itself is the source of truth. These tests pin the
* coarse mapping that drives the Provider column (and any later
* surface that reuses `useAuthProviderLabels`). New `auth.*` actions
* that land in the audit log must be wired into `providerForAction`
* with a corresponding regression case here.
*/
describe("providerForAction", () => {
it("classifies passkey actions as `passkey`", () => {
expect(providerForAction("auth.login.passkey")).toBe("passkey");
expect(providerForAction("auth.passkey.register")).toBe("passkey");
expect(providerForAction("auth.passkey.delete")).toBe("passkey");
});
it("classifies password actions and failed sign-ins as `password`", () => {
expect(providerForAction("auth.login.password")).toBe("password");
expect(providerForAction("auth.password.change")).toBe("password");
// Failed logins flow through the password endpoint; treating them
// as `password` is the truthful summary of how the credential was
// offered. A failed passkey attempt has its own audit row.
expect(providerForAction("auth.login.failed")).toBe("password");
});
it("classifies bearer + native-token actions as `api_token`", () => {
expect(providerForAction("auth.bearer.success")).toBe("api_token");
expect(providerForAction("auth.bearer.failure")).toBe("api_token");
expect(providerForAction("auth.token.autoissue.native")).toBe("api_token");
expect(providerForAction("auth.token.refresh")).toBe("api_token");
expect(providerForAction("auth.token.refresh.failed")).toBe("api_token");
expect(providerForAction("auth.token.refresh.revoke")).toBe("api_token");
expect(providerForAction("auth.token.revoke")).toBe("api_token");
});
it("classifies Withings OAuth events as `withings`", () => {
expect(providerForAction("auth.withings.connect")).toBe("withings");
expect(providerForAction("auth.withings.disconnect")).toBe("withings");
});
it("falls back to `unknown` for unmapped or generic auth actions", () => {
expect(providerForAction("auth.login")).toBe("unknown");
expect(providerForAction("auth.logout")).toBe("unknown");
expect(providerForAction("auth.register")).toBe("unknown");
expect(providerForAction("admin.user.delete")).toBe("unknown");
expect(providerForAction("")).toBe("unknown");
});
});
describe("iconForAuthProvider", () => {
it("maps each provider to the documented lucide glyph", () => {
// Marc's brief pinned the exact icon for each provider so the
// visual language stays consistent across the admin surface.
expect(iconForAuthProvider("password")).toBe(KeyRound);
expect(iconForAuthProvider("passkey")).toBe(Fingerprint);
expect(iconForAuthProvider("api_token")).toBe(Cpu);
expect(iconForAuthProvider("withings")).toBe(Globe);
// `unknown` reuses the OAuth glyph rather than a `?` so the
// column never paints a missing icon.
expect(iconForAuthProvider("unknown")).toBe(Globe);
});
});
+187
View File
@@ -3,6 +3,7 @@
import { useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input";
@@ -81,6 +82,9 @@ export interface AdminSettings {
bugReportEnabled: boolean;
reminderLateMinutes: number;
reminderMissedMinutes: number;
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
defaultUserTimezone: string | null;
moodLogGlobal?: boolean;
}
export interface AdminAuditEntry {
@@ -286,6 +290,189 @@ export function useAuthActionLabels(): Record<string, string> {
);
}
/**
* v1.4.25 W8b — Provider column for the Login-Übersicht.
*
* The audit log doesn't have a dedicated `provider` field; the action
* name itself encodes how the login happened (`auth.login.passkey`,
* `auth.bearer.success`, …). We derive a coarse provider tag from
* the action so admins can see at a glance whether each row is a
* password, passkey, API token, or OAuth (Withings) event.
*
* Keep the mapping exhaustive — every `auth.*` action that lands in
* the audit table must resolve to one of `password | passkey |
* api_token | withings | unknown`. New actions are wired here once,
* mirroring the convention established by `useAuthActionLabels`.
*/
export type AuthProvider =
| "password"
| "passkey"
| "api_token"
| "withings"
| "unknown";
export function providerForAction(action: string): AuthProvider {
if (
action === "auth.login.passkey" ||
action === "auth.passkey.register" ||
action === "auth.passkey.delete"
) {
return "passkey";
}
if (
action === "auth.login.password" ||
action === "auth.password.change" ||
// Failed sign-ins go through the password endpoint — the passkey
// flow has its own `auth.login.failed` row but `details.reason`
// disambiguates further; for the column tag, "password" is the
// truthful summary of how the credential was offered.
action === "auth.login.failed"
) {
return "password";
}
if (
action === "auth.bearer.success" ||
action === "auth.bearer.failure" ||
action === "auth.token.autoissue.native" ||
action === "auth.token.refresh" ||
action === "auth.token.refresh.failed" ||
action === "auth.token.refresh.revoke" ||
action === "auth.token.revoke"
) {
return "api_token";
}
if (action.startsWith("auth.withings")) {
return "withings";
}
return "unknown";
}
const AUTH_PROVIDER_ICONS = {
password: KeyRound,
passkey: Fingerprint,
api_token: Cpu,
withings: Globe,
unknown: Globe,
} as const;
export function iconForAuthProvider(
provider: AuthProvider,
): React.ComponentType<{ className?: string }> {
return AUTH_PROVIDER_ICONS[provider];
}
/**
* i18n label map for the Provider column. Keys match `AuthProvider`
* so callers can index directly: `labels[providerForAction(action)]`.
*/
export function useAuthProviderLabels(): Record<AuthProvider, string> {
const { t, locale } = useTranslations();
return useMemo(
() => ({
password: t("admin.providerPassword"),
passkey: t("admin.providerPasskey"),
api_token: t("admin.providerApiToken"),
withings: t("admin.providerWithings"),
unknown: t("admin.providerUnknown"),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale],
);
}
/**
* v1.4.25 W8b — Build the CSV record set for the audit-log export.
*
* Pulled out of the section component so the column order, header
* mapping, and provider/outcome derivation are reachable from a
* pure unit test without rendering the React tree. Marc's spec pins
* the column order at `timestamp → user → IP → location → provider
* → outcome` (email is absent from the audit-log API and would
* require schema/API changes that are out of scope). `action` and
* `details` follow so the export keeps the full triage payload.
*
* `formatTimestamp` is injected (not imported) so the production
* callsite passes the user-tz formatter (`formatInUserTz`) while the
* unit test passes a deterministic stub.
*/
export interface AuditCsvEntry {
createdAt: string;
action: string;
ipAddress: string | null;
location: string | null;
details: string | null;
user: { id: string; username: string } | null;
}
export interface AuditCsvLabels {
timestamp: string;
user: string;
ip: string;
location: string;
provider: string;
outcome: string;
action: string;
details: string;
outcomeFailed: string;
outcomeSuccess: string;
unknownUser: string;
providerLabels: Record<AuthProvider, string>;
}
export interface AuditCsvRecord {
timestamp: string;
user: string;
ip: string;
location: string;
provider: string;
outcome: string;
action: string;
details: string;
// Index signature so the record satisfies the structural
// `ExportableRecord` contract used by `toCSV`. Fixed columns above
// still drive the order of the emitted CSV.
[key: string]: string;
}
export function buildAuditLogCsvRecords(
entries: AuditCsvEntry[],
labels: AuditCsvLabels,
formatTimestamp: (iso: string) => string,
): AuditCsvRecord[] {
return entries.map((entry) => {
const provider = providerForAction(entry.action);
const isFailed =
entry.action === "auth.login.failed" ||
entry.action === "auth.bearer.failure" ||
entry.action === "auth.token.refresh.failed";
return {
timestamp: formatTimestamp(entry.createdAt),
user: entry.user?.username ?? labels.unknownUser,
ip: entry.ipAddress ?? "",
location: entry.location ?? "",
provider: labels.providerLabels[provider],
outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess,
action: entry.action,
details: entry.details ?? "",
};
});
}
export function auditLogCsvHeaderLabels(
labels: AuditCsvLabels,
): Record<keyof AuditCsvRecord, string> {
return {
timestamp: labels.timestamp,
user: labels.user,
ip: labels.ip,
location: labels.location,
provider: labels.provider,
outcome: labels.outcome,
action: labels.action,
details: labels.details,
};
}
export function useSystemStatus() {
return useQuery({
queryKey: ["admin", "status"],
+14 -3
View File
@@ -151,8 +151,13 @@ export function AdminShell({ active, children }: AdminShellProps) {
const { t } = useTranslations();
const activeSlug = deriveActiveSlug(pathname, active);
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
// already, so this inner shell only carries the wider max-width.
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
// producing visibly more top/bottom whitespace on Settings/Admin
// pages than on Dashboard/Insights/Measurements.
return (
<div className="mx-auto w-full max-w-screen-xl px-4 py-6 md:px-6 md:py-8">
<div className="mx-auto w-full max-w-screen-xl">
{/* Mobile section strip — horizontal scroll, hidden on md+.
`no-scrollbar` (defined in `globals.css`) suppresses the
painted scrollbar at the top of every admin page; the
@@ -170,7 +175,10 @@ export function AdminShell({ active, children }: AdminShellProps) {
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
// v1.4.25 W8 — chip strip is the primary mobile-admin
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
activeSlug === null
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
@@ -189,7 +197,10 @@ export function AdminShell({ active, children }: AdminShellProps) {
href={`/admin/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
// v1.4.25 W8 — chip strip is the primary mobile-admin
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
+99 -106
View File
@@ -64,125 +64,118 @@ export function CoachFeedbackSection() {
},
});
if (query.isLoading) {
return (
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.coachFeedback.loading")}
</span>
</div>
);
}
if (query.isError) {
return (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-xl border p-4 text-sm"
>
{t("admin.coachFeedback.loadError")}
</div>
);
}
const summary = query.data;
const summary = query.data ?? null;
const buckets = summary?.coachBuckets ?? [];
const hasData = !!summary && buckets.length > 0;
if (!summary || buckets.length === 0) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.coachFeedback.title")}
</div>
</div>
<p className="text-muted-foreground mt-3 text-sm">
{t("admin.coachFeedback.empty")}
</p>
</div>
);
}
// v1.4.25 W8 — keep the section's outer card + header rendered across
// every query state (loading / error / empty / data) so the heading
// baseline stays at a constant Y-offset. Previously the loading branch
// returned a thin flex-row stub and the error branch a p-4 alert with
// NO heading, which made the entire section snap downward when the
// fetched data resolved — Marc reported the visible layout shift on
// /admin/coach-feedback. Mirrors the canonical structure used by
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.coachFeedback.title")}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
{hasData && summary && (
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="coach-feedback-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colPromptVersion")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colTone")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colVerbosity")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colNotHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colN")}
</th>
<th className="py-2 font-medium tabular-nums">
{t("admin.coachFeedback.colHelpfulRate")}
</th>
</tr>
</thead>
<tbody>
{buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
className="border-border/40 border-b last:border-0"
data-slot="coach-feedback-bucket"
>
<td className="text-muted-foreground py-2 pr-3 text-xs">
{bucket.promptVersion}
</td>
<td className="py-2 pr-3">{bucket.tone}</td>
<td className="py-2 pr-3">{bucket.verbosity}</td>
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
<td className="py-2 pr-3 tabular-nums">
{bucket.notHelpful}
</td>
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
<td
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
bucket.helpfulRate,
)}`}
{query.isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.coachFeedback.loading")}
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.coachFeedback.loadError")}
</div>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.coachFeedback.empty")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="coach-feedback-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colPromptVersion")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colTone")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colVerbosity")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colNotHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colN")}
</th>
<th className="py-2 font-medium tabular-nums">
{t("admin.coachFeedback.colHelpfulRate")}
</th>
</tr>
</thead>
<tbody>
{buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
className="border-border/40 border-b last:border-0"
data-slot="coach-feedback-bucket"
>
{ratePct}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<td className="text-muted-foreground py-2 pr-3 text-xs">
{bucket.promptVersion}
</td>
<td className="py-2 pr-3">{bucket.tone}</td>
<td className="py-2 pr-3">{bucket.verbosity}</td>
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
<td className="py-2 pr-3 tabular-nums">
{bucket.notHelpful}
</td>
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
<td
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
bucket.helpfulRate,
)}`}
>
{ratePct}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -1,13 +1,23 @@
"use client";
import { useMemo } from "react";
import { Settings } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
import { listSupportedTimezones } from "@/lib/tz/format";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none";
export function GeneralSettingsSection() {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
// Memoised — `listSupportedTimezones()` calls the engine's
// `Intl.supportedValuesOf('timeZone')` which returns ~400 strings.
const zones = useMemo(() => listSupportedTimezones(), []);
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
return (
<div className="bg-card border-border rounded-xl border p-6">
@@ -39,12 +49,47 @@ export function GeneralSettingsSection() {
updateSettings.mutate({ defaultLocale: e.target.value })
}
disabled={updateSettings.isPending}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"
className={NATIVE_SELECT_CLASS}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
{/* v1.4.25 W7 — server-wide default timezone for new
signups. Existing accounts are NOT touched; this drives
only the registration handler's fallback when the
client doesn't send a browser-detected zone. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">
{t("admin.defaultUserTimezone")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.defaultUserTimezoneDescription")}
</p>
</div>
<select
value={currentDefaultTz}
onChange={(e) =>
updateSettings.mutate({ defaultUserTimezone: e.target.value })
}
disabled={updateSettings.isPending}
className={NATIVE_SELECT_CLASS}
>
<option value="">{t("admin.defaultUserTimezoneFallback")}</option>
{/* Preserve the stored value even if it's not in the
current engine's IANA list (rare — but defensive). */}
{currentDefaultTz && !zones.includes(currentDefaultTz) && (
<option value={currentDefaultTz}>{currentDefaultTz}</option>
)}
{zones.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
</select>
</div>
</div>
</div>
);
+314 -281
View File
@@ -4,9 +4,9 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
ChevronDown,
Download,
Loader2,
MapPin,
ScrollText,
XCircle,
} from "lucide-react";
@@ -23,7 +23,17 @@ import {
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { toCSV } from "@/lib/export";
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { useAuth } from "@/hooks/use-auth";
import {
type AdminAuditEntry,
auditLogCsvHeaderLabels,
buildAuditLogCsvRecords,
iconForAuthProvider,
providerForAction,
useAuthActionLabels,
useAuthProviderLabels,
} from "./_shared";
type DateRangePreset = "all" | "24h" | "7d" | "30d";
type PerPageValue = 25 | 50 | 100;
@@ -50,11 +60,20 @@ function rangeToSince(range: DateRangePreset): string | undefined {
export function LoginOverviewSection() {
const { t } = useTranslations();
// v1.5 phase-4b moved this to a dedicated route
// (`/admin/login-overview`), so the user has already opted into the
// audit log by visiting the page. Default to expanded; the toggle
// stays as an escape hatch.
const [expanded, setExpanded] = useState(true);
const { user } = useAuth();
// v1.4.25 W7 — every CSV-emitted timestamp uses the admin's display
// timezone so the resulting `2026-05-11T11:05:00+02:00` cell stays
// legible after Excel/LibreOffice strip the `Z` suffix from the
// legacy UTC export.
const userTz = user?.timezone ?? DEFAULT_TIMEZONE;
// v1.4.25 W8b — the section used to wrap its body in a
// collapse/expand toggle. Since v1.5 phase-4b moved this to a
// dedicated `/admin/login-overview` route (the only thing on the
// page), the toggle was pure clutter: visiting the page already
// signals intent to see the audit log. The toggle is gone; content
// always renders. The `settings.collapse` / `settings.expand`
// i18n keys are retained centrally for any future surface.
// Quick-filter pill (kept from the v1.4.x UI for one-tap "show failed").
const [filter, setFilter] = useState<"all" | "failed">("all");
@@ -68,6 +87,7 @@ export function LoginOverviewSection() {
const [perPage, setPerPage] = useState<PerPageValue>(50);
const AUTH_ACTION_LABELS = useAuthActionLabels();
const AUTH_PROVIDER_LABELS = useAuthProviderLabels();
// Build the query string once so it's reused by the data-query key, the
// export download, and the next-/prev- buttons.
@@ -115,7 +135,6 @@ export function LoginOverviewSection() {
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as AuditLogResponse;
},
enabled: expanded,
});
// Distinct actions for the dropdown — populated lazily.
@@ -126,7 +145,6 @@ export function LoginOverviewSection() {
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as { actions: string[] };
},
enabled: expanded,
staleTime: 5 * 60_000,
});
@@ -136,16 +154,24 @@ export function LoginOverviewSection() {
function downloadCsv() {
if (entries.length === 0) return;
const records = entries.map((entry) => ({
timestamp: entry.createdAt,
actor_id: entry.user?.id ?? "",
actor_username: entry.user?.username ?? "",
action: entry.action,
ip_address: entry.ipAddress ?? "",
location: entry.location ?? "",
details: entry.details ?? "",
}));
const csv = toCSV(records);
const labels = {
timestamp: t("admin.timestamp"),
user: t("admin.users"),
ip: t("admin.ip"),
location: t("admin.location"),
provider: t("admin.provider"),
outcome: t("admin.outcome"),
action: t("admin.action"),
details: t("admin.auditDetails"),
outcomeFailed: t("admin.outcomeFailed"),
outcomeSuccess: t("admin.outcomeSuccess"),
unknownUser: t("common.unknown"),
providerLabels: AUTH_PROVIDER_LABELS,
};
const records = buildAuditLogCsvRecords(entries, labels, (iso) =>
formatInUserTz(new Date(iso), userTz, "iso-with-offset"),
);
const csv = toCSV(records, auditLogCsvHeaderLabels(labels));
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -167,292 +193,299 @@ export function LoginOverviewSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.loginOverview")}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
{expanded ? t("settings.collapse") : t("settings.expand")}
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
</div>
{expanded && (
<div className="mt-4 space-y-3">
{/* Quick-filter pills. Failed-only is the most common
<div className="mt-4 space-y-3">
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.allAuthEvents")}
</Button>
<Button
variant={filter === "failed" ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
>
{t("admin.failedOnly")}
</Button>
</div>
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.allAuthEvents")}
</Button>
<Button
variant={filter === "failed" ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
>
{t("admin.failedOnly")}
</Button>
</div>
{/* Detailed filter row */}
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="search"
placeholder={t("admin.section.auditLog.filterActor")}
value={actor}
onChange={(e) => {
setPage(1);
setActor(e.target.value);
}}
aria-label={t("admin.section.auditLog.filterActor")}
/>
{/* Detailed filter row */}
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="search"
placeholder={t("admin.section.auditLog.filterActor")}
value={actor}
onChange={(e) => {
setPage(1);
setActor(e.target.value);
}}
aria-label={t("admin.section.auditLog.filterActor")}
/>
<Select
value={actionFilter || "__all__"}
onValueChange={(v) => {
setPage(1);
setActionFilter(v === "__all__" ? "" : v);
}}
>
<SelectTrigger
aria-label={t("admin.section.auditLog.filterAction")}
>
<SelectValue
placeholder={t("admin.section.auditLog.filterAction")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
{t("admin.section.auditLog.filterActionAll")}
</SelectItem>
{(actionsData?.actions ?? [])
.filter((a) => a.startsWith("auth."))
.map((a) => (
<SelectItem key={a} value={a}>
{AUTH_ACTION_LABELS[a] ?? a}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="search"
placeholder={t("admin.section.auditLog.filterTarget")}
value={target}
onChange={(e) => {
setPage(1);
setTarget(e.target.value);
}}
aria-label={t("admin.section.auditLog.filterTarget")}
/>
<Select
value={range}
onValueChange={(v) => {
setPage(1);
setRange(v as DateRangePreset);
}}
>
<SelectTrigger aria-label={t("admin.section.auditLog.filterDate")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="24h">
{t("admin.section.auditLog.range24h")}
</SelectItem>
<SelectItem value="7d">
{t("admin.section.auditLog.range7d")}
</SelectItem>
<SelectItem value="30d">
{t("admin.section.auditLog.range30d")}
</SelectItem>
<SelectItem value="all">
{t("admin.section.auditLog.rangeAll")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Toolbar row: per-page + export */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("admin.section.auditLog.perPage")}
</span>
<Select
value={actionFilter || "__all__"}
value={String(perPage)}
onValueChange={(v) => {
setPage(1);
setActionFilter(v === "__all__" ? "" : v);
setPerPage(Number(v) as PerPageValue);
}}
>
<SelectTrigger
aria-label={t("admin.section.auditLog.filterAction")}
>
<SelectValue
placeholder={t("admin.section.auditLog.filterAction")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
{t("admin.section.auditLog.filterActionAll")}
</SelectItem>
{(actionsData?.actions ?? [])
.filter((a) => a.startsWith("auth."))
.map((a) => (
<SelectItem key={a} value={a}>
{AUTH_ACTION_LABELS[a] ?? a}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="search"
placeholder={t("admin.section.auditLog.filterTarget")}
value={target}
onChange={(e) => {
setPage(1);
setTarget(e.target.value);
}}
aria-label={t("admin.section.auditLog.filterTarget")}
/>
<Select
value={range}
onValueChange={(v) => {
setPage(1);
setRange(v as DateRangePreset);
}}
>
<SelectTrigger
aria-label={t("admin.section.auditLog.filterDate")}
>
<SelectTrigger className="h-8 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="24h">
{t("admin.section.auditLog.range24h")}
</SelectItem>
<SelectItem value="7d">
{t("admin.section.auditLog.range7d")}
</SelectItem>
<SelectItem value="30d">
{t("admin.section.auditLog.range30d")}
</SelectItem>
<SelectItem value="all">
{t("admin.section.auditLog.rangeAll")}
</SelectItem>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
onClick={downloadCsv}
disabled={entries.length === 0}
>
<Download className="mr-1 h-3.5 w-3.5" />
{t("admin.section.auditLog.export")}
</Button>
</div>
{/* Toolbar row: per-page + export */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("admin.section.auditLog.perPage")}
</span>
<Select
value={String(perPage)}
onValueChange={(v) => {
setPage(1);
setPerPage(Number(v) as PerPageValue);
}}
>
<SelectTrigger className="h-8 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
onClick={downloadCsv}
disabled={entries.length === 0}
>
<Download className="mr-1 h-3.5 w-3.5" />
{t("admin.section.auditLog.export")}
</Button>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : !entries.length ? (
<EmptyState
icon={<ScrollText className="size-6" />}
title={
filter === "failed"
? t("admin.loginEmptyFailedTitle")
: t("admin.section.auditLog.empty")
}
description={
filter === "failed"
? t("admin.loginEmptyFailedDescription")
: t("admin.loginEmptyDescription")
}
action={
filter === "failed" ? (
<Button
variant="outline"
size="sm"
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.loginEmptyResetFilter")}
</Button>
) : undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.status")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.action")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.ip")}
</th>
<th className="px-3 py-2 text-right font-medium">
) : !entries.length ? (
<EmptyState
icon={<ScrollText className="size-6" />}
title={
filter === "failed"
? t("admin.loginEmptyFailedTitle")
: t("admin.section.auditLog.empty")
}
description={
filter === "failed"
? t("admin.loginEmptyFailedDescription")
: t("admin.loginEmptyDescription")
}
action={
filter === "failed" ? (
<Button
variant="outline"
size="sm"
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.loginEmptyResetFilter")}
</Button>
) : undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.status")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.action")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.provider")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.ip")}
</th>
{/*
v1.4.25 W8b — Standort column. Previously rendered
`text-right`, which on wide audit tables let the
`Berlin, DE` label drift to the table edge and
blend into the timestamp gutter. Left-aligning it
(and dropping the `text-right` from IP for
consistency) makes Standort a first-class column
that admins actually notice.
*/}
<th className="px-3 py-2 text-left font-medium">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3" aria-hidden="true" />
{t("admin.location")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{entries.map((entry, i) => {
const isFailed = entry.action === "auth.login.failed";
return (
<tr
key={entry.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
</span>
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{entries.map((entry, i) => {
const isFailed = entry.action === "auth.login.failed";
const provider = providerForAction(entry.action);
const ProviderIcon = iconForAuthProvider(provider);
return (
<tr
key={entry.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</td>
<td
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
>
<td className="px-3 py-2">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</td>
<td
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
>
{entry.user?.username ?? t("common.unknown")}
</td>
<td
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</td>
<td className="text-muted-foreground px-3 py-2 text-right font-mono text-xs">
{entry.ipAddress ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
{entry.location ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
{entry.user?.username ?? t("common.unknown")}
</td>
<td
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
<span className="inline-flex items-center gap-1.5">
<ProviderIcon
className="h-3.5 w-3.5"
aria-hidden="true"
/>
{AUTH_PROVIDER_LABELS[provider]}
</span>
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
{entry.ipAddress ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{entry.location ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
<span>
{t("admin.showingEntries", {
count: entries.length,
total,
})}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t("admin.section.auditLog.prev")}
</Button>
<span>
{t("admin.showingEntries", {
count: entries.length,
total,
{t("admin.section.auditLog.pageOf", {
page,
total: lastPage,
})}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t("admin.section.auditLog.prev")}
</Button>
<span>
{t("admin.section.auditLog.pageOf", {
page,
total: lastPage,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage}
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
>
{t("admin.section.auditLog.next")}
</Button>
</div>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage}
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
>
{t("admin.section.auditLog.next")}
</Button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import {
ChartOverlayControls,
ChartOverlayControlsBody,
DEFAULT_CHART_OVERLAY_PREFS,
type ChartOverlayPrefs,
} from "../chart-overlay-controls";
@@ -95,4 +96,135 @@ describe("<ChartOverlayControls>", () => {
expect(html).toContain('aria-label="Chart-Overlay-Einstellungen"');
});
/**
* v1.4.25 W3f — comparison-baseline grey-out when the parent chart
* reports `hasComparisonData=false`. The disabled state communicates
* "you selected a comparison but there's no prior-period series to
* paint" instead of silently hiding the overlay.
*
* The popover body is portalled by Radix at runtime — it's not in
* SSR markup. The tests target the extracted `<ChartOverlayControlsBody>`
* so the JSX inside the popover is renderable in `renderToStaticMarkup`.
*/
describe("comparison-baseline grey-out (v1.4.25 W3f)", () => {
it("renders comparison buttons in normal state when prior data exists", () => {
const prefs: ChartOverlayPrefs = {
...DEFAULT_CHART_OVERLAY_PREFS,
comparisonBaseline: "lastMonth",
};
const html = renderToStaticMarkup(
withProvider(
<ChartOverlayControlsBody
prefs={prefs}
onChange={vi.fn()}
hasComparisonData
/>,
),
);
expect(html).not.toContain('data-comparison-disabled="true"');
expect(html).not.toContain('data-comparison-greyed="true"');
expect(html).not.toContain(
'data-slot="chart-overlay-comparison-unavailable-hint"',
);
// The lastMonth button has no aria-disabled set when data is
// available.
expect(html).not.toMatch(
/data-slot="chart-overlay-comparison-lastMonth"[^>]*aria-disabled="true"/,
);
});
it("greys out lastMonth + lastYear when prior period is empty AND a baseline is selected", () => {
const prefs: ChartOverlayPrefs = {
...DEFAULT_CHART_OVERLAY_PREFS,
comparisonBaseline: "lastMonth",
};
const html = renderToStaticMarkup(
withProvider(
<ChartOverlayControlsBody
prefs={prefs}
onChange={vi.fn()}
hasComparisonData={false}
/>,
),
);
expect(html).toContain('data-comparison-disabled="true"');
// The lastMonth + lastYear buttons grey out, "none" does not.
expect(html).toMatch(
/data-slot="chart-overlay-comparison-lastMonth"[^>]*data-comparison-greyed="true"/,
);
expect(html).toMatch(
/data-slot="chart-overlay-comparison-lastYear"[^>]*data-comparison-greyed="true"/,
);
// The "none" button stays enabled — it's the escape hatch.
expect(html).not.toMatch(
/data-slot="chart-overlay-comparison-none"[^>]*data-comparison-greyed="true"/,
);
// aria-disabled + opacity-50 + tooltip surface.
expect(html).toMatch(
/data-slot="chart-overlay-comparison-lastMonth"[^>]*aria-disabled="true"/,
);
expect(html).toContain("opacity-50");
expect(html).toContain("No prior-period data yet");
// The footer hint paragraph is also rendered so screen readers
// hear the explanation.
expect(html).toContain(
'data-slot="chart-overlay-comparison-unavailable-hint"',
);
});
it("keeps all buttons enabled when the baseline is 'none' even with no prior data", () => {
// The "none" baseline is the safe default — the grey-out only
// triggers once a user has opted in to comparing.
const html = renderToStaticMarkup(
withProvider(
<ChartOverlayControlsBody
prefs={DEFAULT_CHART_OVERLAY_PREFS}
onChange={vi.fn()}
hasComparisonData={false}
/>,
),
);
expect(html).not.toContain('data-comparison-disabled="true"');
expect(html).not.toContain('data-comparison-greyed="true"');
expect(html).not.toContain(
'data-slot="chart-overlay-comparison-unavailable-hint"',
);
});
it("emits the localised tooltip + hint copy under de locale", () => {
const prefs: ChartOverlayPrefs = {
...DEFAULT_CHART_OVERLAY_PREFS,
comparisonBaseline: "lastYear",
};
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<ChartOverlayControlsBody
prefs={prefs}
onChange={vi.fn()}
hasComparisonData={false}
/>
</I18nProvider>,
);
expect(html).toContain("Kein Vorzeitraum verfügbar");
});
it("defaults hasComparisonData to true when the prop is omitted", () => {
// Charts that haven't been updated to thread the flag keep the
// pre-W3f behaviour — every button stays enabled.
const prefs: ChartOverlayPrefs = {
...DEFAULT_CHART_OVERLAY_PREFS,
comparisonBaseline: "lastMonth",
};
const html = renderToStaticMarkup(
withProvider(
<ChartOverlayControlsBody prefs={prefs} onChange={vi.fn()} />,
),
);
expect(html).not.toContain('data-comparison-disabled="true"');
});
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { resolveVerticalMarkerPositions } from "../health-chart";
/**
* v1.4.25 W6 — GLP-1 injection-day vertical-marker pinning.
*
* The helper maps incoming `{date, label?, color?}[]` markers onto the
* bucketed chart series, returning the pointIndex of every match plus
* the marker's color/label so the chart paints exactly one
* `<ReferenceLine>` per matched bucket. Differs from the storyboard
* `resolveAnnotationPositions` helper in two ways: it does an EXACT
* day-key match (no ±7-day snap, no truncation), and it defaults the
* color to the strip-tile green when omitted.
*
* Pure helper — the suite pins behaviour without mounting Recharts.
*/
describe("resolveVerticalMarkerPositions", () => {
const dailyChartData = Array.from({ length: 10 }, (_, i) => ({
date: `2026-04-${String(15 + i).padStart(2, "0")}`,
}));
it("returns empty when markers is undefined", () => {
expect(resolveVerticalMarkerPositions(undefined, dailyChartData)).toEqual(
[],
);
});
it("returns empty when markers is an empty array", () => {
expect(resolveVerticalMarkerPositions([], dailyChartData)).toEqual([]);
});
it("returns empty when chartData is undefined or empty", () => {
expect(
resolveVerticalMarkerPositions([{ date: "2026-04-15" }], undefined),
).toEqual([]);
expect(
resolveVerticalMarkerPositions([{ date: "2026-04-15" }], []),
).toEqual([]);
});
it("pins a single in-window marker to its exact day-key", () => {
const positions = resolveVerticalMarkerPositions(
[{ date: "2026-04-15" }],
dailyChartData,
);
expect(positions).toHaveLength(1);
expect(positions[0].pointIndex).toBe(0);
expect(positions[0].color).toBe("#50fa7b");
expect(positions[0].label).toBeUndefined();
});
it("pins multiple markers in input order with the correct pointIndex", () => {
const positions = resolveVerticalMarkerPositions(
[{ date: "2026-04-15" }, { date: "2026-04-22" }, { date: "2026-04-19" }],
dailyChartData,
);
expect(positions.map((p) => p.pointIndex)).toEqual([0, 7, 4]);
});
it("respects caller-supplied color + label overrides", () => {
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-17", label: "Mounjaro 7.5", color: "#ff79c6" },
{ date: "2026-04-19" },
],
dailyChartData,
);
expect(positions[0].color).toBe("#ff79c6");
expect(positions[0].label).toBe("Mounjaro 7.5");
// Default color preserved for the second marker.
expect(positions[1].color).toBe("#50fa7b");
expect(positions[1].label).toBeUndefined();
});
it("drops markers whose date does not match any visible day-key", () => {
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-15" }, // exact match — index 0
{ date: "2026-03-01" }, // off-window
{ date: "2026-05-15" }, // off-window
{ date: "2026-04-24" }, // off-window (visible ends at 2026-04-24? series is 15..24)
],
dailyChartData,
);
// Series is 2026-04-15 .. 2026-04-24. 2026-04-24 is the last entry.
expect(positions.map((p) => p.pointIndex)).toEqual([0, 9]);
});
it("handles a sparse weekly-bucket series with exact-day matching only", () => {
// Weekly buckets — only the date keys that fall on a bucket day land.
const weeklyChartData = ["2026-04-15", "2026-04-22", "2026-04-29"].map(
(date) => ({ date }),
);
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-22" }, // exact bucket day → matches
{ date: "2026-04-23" }, // between buckets → drops (no ±7 snap)
],
weeklyChartData,
);
expect(positions).toHaveLength(1);
expect(positions[0].pointIndex).toBe(1);
});
});
@@ -0,0 +1,221 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Activity, Heart, Smile, Percent, TrendingUp } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.25 W20a — regression coverage for the dashboard top-tile polish.
*
* Marc-direktive 2026-05-14 named three issues with the 5-tile top row:
*
* 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood
* Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading
* row to a deterministic `h-5` with `whitespace-nowrap` so even an
* unexpectedly long string never breaks the baseline of the value
* row below.
* 2. Trend arrow rendered as a wrapping flex child after the unit —
* sometimes wrapped to its own line, sometimes missing entirely
* when slope30 was null. Now sits in a fixed `h-4 w-4` slot with
* `items-baseline` alignment; when there's no slope yet a muted
* "—" placeholder keeps the slot reserved.
* 3. Value rows of two adjacent tiles (e.g. "Weight 80 kg" vs.
* "Blood Pressure 122 mmHg") landed at different y-coordinates
* because heading-row height was variable. The fixed heading
* height plus `text-3xl leading-none` on the value forces every
* tile in the strip to share the same baseline.
*
* These tests pin the load-bearing class names so a future refactor
* that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a
* red CI before it reaches the dashboard.
*/
const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 };
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<TrendCard> W20a baseline alignment", () => {
it("locks the heading row to a deterministic height", () => {
const html = render(
<TrendCard
label="Weight"
latest={80}
unit="kg"
avg7={80}
avg30={80}
slope30={RISING}
icon={Activity}
/>,
);
// h-5 is the load-bearing class — keeps the value row below at the
// same y-coordinate regardless of the label string's actual width.
expect(html).toContain("h-5");
expect(html).toContain('data-slot="trend-card-label"');
});
it("forbids wrapping on the heading row even with a long label", () => {
const html = render(
<TrendCard
// Stress test — the live tile uses dashboard.*Short keys but
// we want the layout to survive an i18n regression that wires
// a long form by accident.
label="A really long blood pressure metric name"
latest={122}
unit="mmHg"
avg7={120}
avg30={119}
slope30={RISING}
icon={Heart}
/>,
);
expect(html).toContain("whitespace-nowrap");
expect(html).toContain("truncate");
});
it("renders the value row with baseline alignment", () => {
const html = render(
<TrendCard
label="Weight"
latest={80}
unit="kg"
avg7={80}
avg30={80}
slope30={RISING}
icon={Activity}
/>,
);
expect(html).toContain('data-slot="trend-card-value-row"');
expect(html).toContain("items-baseline");
// Headline font-size + zero line-height — across-tile baseline.
expect(html).toContain("text-3xl");
expect(html).toContain("leading-none");
});
it("places the trend arrow inline at the end of the value row", () => {
const html = render(
<TrendCard
label="Weight"
latest={80}
unit="kg"
avg7={80}
avg30={80}
slope30={RISING}
icon={Activity}
directionSentiment="up-bad"
/>,
);
expect(html).toContain('data-slot="trend-card-arrow"');
// The arrow slot sits inside the value row, so the value-row data
// slot precedes it in the rendered HTML.
const valueRowIdx = html.indexOf('data-slot="trend-card-value-row"');
const arrowIdx = html.indexOf('data-slot="trend-card-arrow"');
expect(valueRowIdx).toBeGreaterThan(-1);
expect(arrowIdx).toBeGreaterThan(valueRowIdx);
});
it("keeps the arrow slot reserved when slope is missing", () => {
const html = render(
<TrendCard
label="Pulse"
latest={68}
unit="bpm"
avg7={null}
avg30={null}
slope30={null}
icon={TrendingUp}
/>,
);
// Slot still rendered (deterministic width across the strip).
expect(html).toContain('data-slot="trend-card-arrow"');
// Placeholder dash + muted opacity so the slot reads as "no data".
expect(html).toMatch(/opacity-30[^"]*"[^>]*>—/);
});
it("renders consistent layout across the five top-row tile metrics", () => {
// Snapshot-style smoke check — every tile in the top row should
// share the same heading-row height + value-row baseline class
// contract so the strip aligns visually on Pixel 5.
const tiles = [
{ label: "Weight", value: 80, unit: "kg", icon: Activity },
{ label: "BP (Sys)", value: 122, unit: "mmHg", icon: Heart },
{ label: "Pulse", value: 68, unit: "bpm", icon: TrendingUp },
{ label: "Body Fat", value: 18.5, unit: "%", icon: Percent },
{ label: "Mood", value: 4, unit: "/ 5", icon: Smile },
];
for (const tile of tiles) {
const html = render(
<TrendCard
label={tile.label}
latest={tile.value}
unit={tile.unit}
avg7={tile.value}
avg30={tile.value}
slope30={RISING}
icon={tile.icon}
/>,
);
// Same heading-row height contract — `h-5` + `whitespace-nowrap`.
expect(html).toContain("h-5");
expect(html).toContain("whitespace-nowrap");
// Same value-row baseline contract — `items-baseline` + `text-3xl`
// + `leading-none`. Identical font geometry across all five tiles
// means the digits land at the same y-coordinate inside the grid.
expect(html).toContain("items-baseline");
expect(html).toContain("text-3xl");
expect(html).toContain("leading-none");
// Same arrow slot contract — fixed width, end-anchored via `ml-auto`.
expect(html).toContain('data-slot="trend-card-arrow"');
expect(html).toContain("ml-auto");
}
});
});
describe("dashboard.*Short keys exist across all six locales", () => {
// Defensive integrity check — the i18n-locale-integrity test guards
// top-level parity across messages/*.json, but a hand-rolled list of
// the exact short keys the dashboard tile strip pulls makes a missing
// key fail with a precise diagnostic rather than a generic "key X is
// missing in locale Y" surface.
const REQUIRED_KEYS = [
"weightShort",
"bloodPressureSysShort",
"bloodPressureDiaShort",
"pulseShort",
"bodyFatShort",
"moodShort",
"sleepShort",
"stepsShort",
"vo2MaxShort",
"bpInTargetShort",
];
// Resolved at test time so the assertion runs against the current
// bundle, not a stale cached snapshot.
const locales = ["en", "de", "fr", "es", "it", "pl"] as const;
it.each(locales)("%s carries every dashboard.*Short key", async (locale) => {
const messages = (await import(`../../../../messages/${locale}.json`))
.default;
const dashboard = messages.dashboard ?? {};
for (const key of REQUIRED_KEYS) {
expect(dashboard[key], `dashboard.${key} missing in ${locale}.json`)
.toBeTypeOf("string");
expect(
(dashboard[key] as string).length,
`dashboard.${key} empty in ${locale}.json`,
).toBeGreaterThan(0);
}
});
});
+157 -82
View File
@@ -70,28 +70,27 @@ export interface ChartOverlayControlsProps {
* wrapper align the cog with its title row.
*/
triggerClassName?: string;
/**
* v1.4.25 W3f — when the parent chart already knows the prior period
* carries no overlay-able rows, it threads `hasComparisonData=false`
* here so the comparison-baseline buttons render in a visibly
* disabled state (reduced opacity + `aria-disabled` + tooltip)
* instead of letting the user pick a value that paints nothing on
* the chart.
*
* Default `true` (i.e. enabled) so charts that never compute the
* flag (or compute it asynchronously) keep their existing behaviour.
*/
hasComparisonData?: boolean;
}
export function ChartOverlayControls({
prefs,
onChange,
triggerClassName,
hasComparisonData = true,
}: ChartOverlayControlsProps): ReactElement {
const { t } = useTranslations();
const trendIndicatorId = useId();
const trendArrowId = useId();
const targetRangeId = useId();
const setToggle = (
key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange",
value: boolean,
): void => {
onChange({ ...prefs, [key]: value });
};
const setComparisonBaseline = (value: ComparisonBaseline): void => {
onChange({ ...prefs, comparisonBaseline: value });
};
return (
<DropdownMenu>
@@ -113,70 +112,135 @@ export function ChartOverlayControls({
className="w-[240px] p-3"
data-slot="chart-overlay-controls-content"
>
<DropdownMenuLabel className="text-xs font-medium tracking-wide uppercase">
{t("chart.overlay.controls.title")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-col gap-2.5 pt-1">
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={trendIndicatorId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.trendIndicator")}
</Label>
<Switch
id={trendIndicatorId}
checked={prefs.showTrendIndicator}
onCheckedChange={(value) =>
setToggle("showTrendIndicator", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-indicator"
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={trendArrowId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.trendArrow")}
</Label>
<Switch
id={trendArrowId}
checked={prefs.showTrendArrow}
onCheckedChange={(value) =>
setToggle("showTrendArrow", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-arrow"
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={targetRangeId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.targetRange")}
</Label>
<Switch
id={targetRangeId}
checked={prefs.showTargetRange}
onCheckedChange={(value) =>
setToggle("showTargetRange", Boolean(value))
}
data-slot="chart-overlay-toggle-target-range"
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-muted-foreground text-xs font-medium">
{t("chart.overlay.controls.comparisonBaseline")}
</Label>
<div
className="grid grid-cols-3 gap-1"
data-slot="chart-overlay-comparison-baseline"
<ChartOverlayControlsBody
prefs={prefs}
onChange={onChange}
hasComparisonData={hasComparisonData}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* v1.4.25 W3f — the dropdown body is extracted so unit tests can render
* it without spinning up Radix's portalled popover (which is invisible
* to `renderToStaticMarkup`). Production callers always go through
* `<ChartOverlayControls>` above; the body export is purely a test
* affordance + a future hook for in-line (non-popover) renders.
*/
export interface ChartOverlayControlsBodyProps {
prefs: ChartOverlayPrefs;
onChange: (next: ChartOverlayPrefs) => void;
hasComparisonData?: boolean;
}
export function ChartOverlayControlsBody({
prefs,
onChange,
hasComparisonData = true,
}: ChartOverlayControlsBodyProps): ReactElement {
const { t } = useTranslations();
const trendIndicatorId = useId();
const trendArrowId = useId();
const targetRangeId = useId();
// v1.4.25 W3f — when the underlying prior-period series is empty for
// BOTH lastMonth and lastYear (we only know about the currently
// selected baseline), grey out the non-"none" buttons. We can only
// verify the active baseline truthfully, so the disabled state is
// gated on `comparisonBaseline !== "none" && !hasComparisonData`:
// the user has already opted in to comparing and we discovered the
// prior period is empty. From the "none" baseline we keep all
// buttons enabled so the user can still toggle in for the chart to
// re-evaluate.
const comparisonGreyOut =
prefs.comparisonBaseline !== "none" && !hasComparisonData;
const disabledTooltip = t("chart.overlay.controls.comparisonUnavailable");
const setToggle = (
key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange",
value: boolean,
): void => {
onChange({ ...prefs, [key]: value });
};
const setComparisonBaseline = (value: ComparisonBaseline): void => {
onChange({ ...prefs, comparisonBaseline: value });
};
return (
<>
<DropdownMenuLabel className="text-xs font-medium tracking-wide uppercase">
{t("chart.overlay.controls.title")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-col gap-2.5 pt-1">
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={trendIndicatorId}
className="cursor-pointer text-xs font-normal"
>
{COMPARISON_BASELINES.map((value) => (
{t("chart.overlay.controls.trendIndicator")}
</Label>
<Switch
id={trendIndicatorId}
checked={prefs.showTrendIndicator}
onCheckedChange={(value) =>
setToggle("showTrendIndicator", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-indicator"
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={trendArrowId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.trendArrow")}
</Label>
<Switch
id={trendArrowId}
checked={prefs.showTrendArrow}
onCheckedChange={(value) =>
setToggle("showTrendArrow", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-arrow"
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={targetRangeId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.targetRange")}
</Label>
<Switch
id={targetRangeId}
checked={prefs.showTargetRange}
onCheckedChange={(value) =>
setToggle("showTargetRange", Boolean(value))
}
data-slot="chart-overlay-toggle-target-range"
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-muted-foreground text-xs font-medium">
{t("chart.overlay.controls.comparisonBaseline")}
</Label>
<div
className="grid grid-cols-3 gap-1"
data-slot="chart-overlay-comparison-baseline"
data-comparison-disabled={comparisonGreyOut ? "true" : undefined}
>
{COMPARISON_BASELINES.map((value) => {
// The "none" choice never greys out — it's the safe
// escape hatch when prior data is missing.
const isNoneOption = value === "none";
const isGreyed = comparisonGreyOut && !isNoneOption;
return (
<Button
key={value}
type="button"
@@ -184,17 +248,28 @@ export function ChartOverlayControls({
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
className="h-9 px-1 text-[11px]"
className={`h-9 px-1 text-[11px] ${isGreyed ? "opacity-50" : ""}`.trim()}
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
aria-disabled={isGreyed || undefined}
title={isGreyed ? disabledTooltip : undefined}
data-slot={`chart-overlay-comparison-${value}`}
data-comparison-greyed={isGreyed ? "true" : undefined}
>
{t(`comparison.baseline.${value}`)}
</Button>
))}
</div>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
{comparisonGreyOut && (
<p
className="text-muted-foreground text-[10px] leading-snug"
data-slot="chart-overlay-comparison-unavailable-hint"
>
{disabledTooltip}
</p>
)}
</div>
</>
);
}
-145
View File
@@ -1,145 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { ComplianceHeatmap } from "./compliance-heatmap";
import { ComplianceLineChart } from "./compliance-line-chart";
interface DailyData {
expected: number;
taken: number;
skipped: number;
onTime?: number;
late?: number;
veryLate?: number;
}
interface ComplianceData {
dailyCompliance: Record<string, DailyData>;
}
interface Medication {
id: string;
name: string;
dose: string;
active: boolean;
}
interface ComplianceChartsProps {
medications: Medication[];
}
export function ComplianceCharts({ medications }: ComplianceChartsProps) {
const { t } = useTranslations();
const [selectedId, setSelectedId] = useState(medications[0]?.id ?? "");
const [rangePoints, setRangePoints] = useState<30 | 90 | 0>(30);
const { data, isLoading } = useQuery({
queryKey: ["compliance-chart", selectedId],
queryFn: async () => {
const res = await fetch(`/api/medications/${selectedId}/compliance`);
if (!res.ok) return null;
const json = await res.json();
return json.data as ComplianceData;
},
enabled: !!selectedId,
staleTime: 60 * 1000,
});
if (medications.length === 0) return null;
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
<div className="flex justify-end">
<Select value={selectedId} onValueChange={setSelectedId}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue placeholder={t("charts.selectMedication")} />
</SelectTrigger>
<SelectContent>
{medications.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} ({m.dose})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !data?.dailyCompliance ? (
<div className="text-muted-foreground flex h-48 items-center justify-center rounded-lg border border-dashed text-sm">
{t("charts.noComplianceData")}
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<div className="min-w-0 space-y-2">
<div className="flex h-7 items-center">
<h3 className="text-muted-foreground text-sm font-medium">
{t("charts.complianceDays90")}
</h3>
</div>
<ComplianceHeatmap dailyCompliance={data.dailyCompliance} />
</div>
<div className="min-w-0 space-y-2">
<div className="flex h-7 items-center justify-between gap-2">
<h3 className="text-muted-foreground text-sm font-medium">
{t("charts.history")}
</h3>
<div className="flex items-center gap-1">
<Button
type="button"
variant={rangePoints === 30 ? "default" : "ghost"}
size="sm"
className="min-h-11 px-3 text-xs"
onClick={() => setRangePoints(30)}
title={t("charts.points30Title")}
>
{t("charts.points30Label")}
</Button>
<Button
type="button"
variant={rangePoints === 90 ? "default" : "ghost"}
size="sm"
className="min-h-11 px-3 text-xs"
onClick={() => setRangePoints(90)}
title={t("charts.points90Title")}
>
{t("charts.points90Label")}
</Button>
<Button
type="button"
variant={rangePoints === 0 ? "default" : "ghost"}
size="sm"
className="min-h-11 px-3 text-xs"
onClick={() => setRangePoints(0)}
title={t("charts.pointsAllTitle")}
>
{t("charts.pointsAllLabel")}
</Button>
</div>
</div>
<ComplianceLineChart
dailyCompliance={data.dailyCompliance}
rangePoints={rangePoints}
onRangePointsChange={setRangePoints}
showRangeControls={false}
/>
</div>
</div>
)}
</div>
);
}
@@ -12,9 +12,10 @@ import {
ReferenceLine,
} from "recharts";
import { Button } from "@/components/ui/button";
import { formatDateShort } from "@/lib/format";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
interface DailyData {
expected: number;
@@ -30,6 +31,12 @@ interface ComplianceLineChartProps {
rangePoints?: 30 | 90 | 0;
onRangePointsChange?: (value: 30 | 90 | 0) => void;
showRangeControls?: boolean;
/**
* v1.4.25 W7b — per-user display timezone for the X-axis labels.
* Defaults to "Europe/Berlin" so older callers keep their previous
* rendering bit-for-bit.
*/
userTimezone?: string;
}
const TIME_RANGES = [
@@ -43,6 +50,7 @@ export function ComplianceLineChart({
rangePoints,
onRangePointsChange,
showRangeControls = true,
userTimezone = "Europe/Berlin",
}: ComplianceLineChartProps) {
const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>(
30,
@@ -50,6 +58,13 @@ export function ComplianceLineChart({
const activeRangePoints = rangePoints ?? internalRangePoints;
const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints;
const viewportWidth = useViewportWidth();
const { locale } = useTranslations();
// v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to
// legacy Europe/Berlin when the prop is omitted.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
const chartData = useMemo(() => {
const points = Object.entries(dailyCompliance)
@@ -58,14 +73,14 @@ export function ComplianceLineChart({
.map(([dateKey, data]) => {
const date = new Date(`${dateKey}T12:00:00.000Z`);
return {
date: formatDateShort(date, true),
date: tzFmt.date(date),
rate: Math.min(100, Math.round((data.taken / data.expected) * 100)),
timestamp: date.getTime(),
};
});
return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points;
}, [dailyCompliance, activeRangePoints]);
}, [dailyCompliance, activeRangePoints, tzFmt]);
return (
<div>
+177 -21
View File
@@ -13,6 +13,7 @@ import {
Legend,
ReferenceLine,
ReferenceArea,
ReferenceDot,
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo } from "react";
@@ -20,8 +21,8 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { Button } from "@/components/ui/button";
import { formatDateShort } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import {
bucketTimeSeries,
pickBucket,
@@ -138,6 +139,37 @@ interface HealthChartProps {
* that needs the same behaviour).
*/
annotations?: Array<{ date: string; label: string; color: string }>;
/**
* v1.4.25 W6 — vertical injection-day markers.
*
* Each entry pins a thin dashed vertical reference line on the chart
* at the data point whose Berlin/user-tz day-key matches `date`
* (`YYYY-MM-DD`). Designed for the GLP-1 dashboard tile + the
* Insights /medikamente sub-page so users can see "I injected on
* these days; here's how my weight responded". Differs from
* `annotations` in three ways:
* 1. No text label by default — these markers represent events
* the user already saw the chart-line shape for; an inline
* label per marker would crowd the canvas.
* 2. A small filled dot sits at the x-axis intersection (mood-
* chart style emoji-on-axis pattern repurposed) so a row of
* green dots reads as "injection cadence" at a glance.
* 3. Default green (`#50fa7b`) to match the strip-tile palette
* for active medications; callers can override per marker.
*
* Off-window markers silently drop via `ifOverflow="discard"`,
* matching the existing annotations contract.
*/
verticalMarkers?: Array<{ date: string; label?: string; color?: string }>;
/**
* v1.4.25 W7b — per-user display timezone. When passed (mount sites
* thread `useAuth().user?.timezone`), x-axis tick labels and the
* per-day bucket keys both render in the user's zone instead of the
* legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older
* callers that haven't yet adopted the prop keep their previous
* behaviour bit-for-bit.
*/
userTimezone?: string;
}
interface ChartDataPoint {
@@ -191,15 +223,22 @@ function getTypeLabel(
return key ? t(key) : type;
}
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/**
* Build a `YYYY-MM-DD` day-key formatter pinned to the requested
* timezone. Memoised per-tz inside the component so we don't allocate a
* new `Intl.DateTimeFormat` on every measurement-row pass.
*/
function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
function toBerlinDayKey(value: string): string {
const parts = BERLIN_DAY_FORMATTER.formatToParts(new Date(value));
function toDayKey(value: string, formatter: Intl.DateTimeFormat): string {
const parts = formatter.formatToParts(new Date(value));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
@@ -275,6 +314,53 @@ export interface ResolvedAnnotation {
truncatedLabel: string;
}
/**
* v1.4.25 W6 — pure helper for the GLP-1 injection-day vertical
* markers. Exported so the unit suite can pin behaviour without
* spinning Recharts. The shape mirrors `resolveAnnotationPositions`
* minus the truncated-label slot.
*
* `chartData[i].date` is the `YYYY-MM-DD` day-key the chart already
* computed when bucketing measurements; we match by exact key so a
* marker only lands on a day the chart actually has a data point for.
* This is intentional — drawing a vertical line through a gap in the
* x-axis paints a hanging line with no data context, which reads as a
* rendering bug not as orientation.
*
* Off-window markers silently drop (the chart's `<ReferenceLine>` also
* uses `ifOverflow="discard"` as a belt-and-braces guard).
*/
export interface ResolvedVerticalMarker {
pointIndex: number;
color: string;
label: string | undefined;
}
export function resolveVerticalMarkerPositions(
markers: Array<{ date: string; label?: string; color?: string }> | undefined,
chartData: Array<{ date: string }> | undefined,
): ResolvedVerticalMarker[] {
if (!markers || !chartData || chartData.length === 0) return [];
const indexByDate = new Map<string, number>();
for (const [i, point] of chartData.entries()) {
// Last-write-wins — multiple bucket-aggregated points should never
// share the same day key, but defensively keep the latest if they
// do.
indexByDate.set(point.date, i);
}
const out: ResolvedVerticalMarker[] = [];
for (const marker of markers) {
const idx = indexByDate.get(marker.date);
if (idx === undefined) continue;
out.push({
pointIndex: idx,
color: marker.color ?? "#50fa7b",
label: marker.label,
});
}
return out;
}
export function resolveAnnotationPositions(
annotations:
| Array<{ date: string; label: string; color: string }>
@@ -367,10 +453,27 @@ export function HealthChart({
compareBaseline = "none",
chartKey,
annotations,
verticalMarkers,
userTimezone = "Europe/Berlin",
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings. `useFormatters()` reads the active UI locale only;
// this builds a locale + userTz pair so a Pacific/Auckland user reads
// their own day on the axis.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// v1.4.25 W7b — per-row day-key formatter used to bucket measurement
// rows by the user's local calendar day. Memoised on userTimezone so
// the inner per-row loop reuses a single Intl.DateTimeFormat.
const dayKeyFormatter = useMemo(
() => makeDayKeyFormatter(userTimezone),
[userTimezone],
);
// v1.4.16 B5c: when a windowOverride is supplied, seed the range
// state from it so the chart pins to that window. Mini mode also
// hides the range tabs, so the user can't change it.
@@ -407,6 +510,10 @@ export function HealthChart({
types.join(","),
valueMode,
bmiDivisor ?? "no-bmi",
// v1.4.25 W7b — bucket keys + tick labels depend on the active
// user timezone, so re-key the cache when it changes. Without
// this, a tz change inside a session would render stale buckets.
userTimezone,
],
queryFn: async () => {
const params = new URLSearchParams();
@@ -451,7 +558,7 @@ export function HealthChart({
continue;
}
const dayKey = toBerlinDayKey(measurement.measuredAt);
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
@@ -475,7 +582,7 @@ export function HealthChart({
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
.map((bucket) => {
const point: ChartDataPoint = {
date: formatDateShort(new Date(bucket.timestamp)),
date: tzFmt.dateShort(new Date(bucket.timestamp)),
timestamp: bucket.timestamp,
};
@@ -525,7 +632,7 @@ export function HealthChart({
).points.map<ChartDataPoint>((point) => {
const date = new Date(point.timestamp);
const out: ChartDataPoint = {
date: formatDateShort(date),
date: tzFmt.dateShort(date),
timestamp: point.timestamp,
};
for (const [type, value] of Object.entries(point.values)) {
@@ -590,7 +697,7 @@ export function HealthChart({
}
return enriched;
}, [data, rangePoints, showMA, showTrend, types]);
}, [data, rangePoints, showMA, showTrend, types, tzFmt]);
// v1.4.16 phase B8 — comparison overlay.
//
@@ -624,7 +731,7 @@ export function HealthChart({
// Index shifted rows by the same day-key the chart already uses.
const shiftedByDay = new Map<string, Record<string, number>>();
for (const row of shifted) {
const dayKey = formatDateShort(new Date(row.timestamp));
const dayKey = tzFmt.dateShort(new Date(row.timestamp));
const slot = shiftedByDay.get(dayKey) ?? {};
for (const [type, value] of Object.entries(row.values)) {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -646,7 +753,7 @@ export function HealthChart({
}
return merged;
});
}, [chartData, effectiveCompareBaseline, data, types]);
}, [chartData, effectiveCompareBaseline, data, types, tzFmt]);
/**
* v1.4.16 phase B8 — true when at least one visible day has a prior-
@@ -918,6 +1025,16 @@ export function HealthChart({
chartData,
);
// v1.4.25 W6 — GLP-1 injection-day vertical markers. Pure-helper
// pattern lets the chart-tests pin the marker resolution without
// mounting Recharts. Off-window markers silently drop here so the
// <ReferenceLine> below never paints a line for an out-of-range
// day.
const verticalMarkerPositions = resolveVerticalMarkerPositions(
verticalMarkers,
chartData,
);
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
@@ -1019,6 +1136,7 @@ export function HealthChart({
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
@@ -1101,11 +1219,10 @@ export function HealthChart({
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
formatDateShort(
tzFmt.date(
new Date(
chartData?.[Math.round(value)]?.timestamp ?? Date.now(),
),
true,
)
}
interval={chooseTickInterval(
@@ -1203,6 +1320,47 @@ export function HealthChart({
}}
/>
))}
{/* v1.4.25 W6 — GLP-1 injection-day vertical markers.
Thin dashed line + a small filled dot at the x-axis
intersection so a row of markers reads as "injection
cadence" without crowding the canvas. Optional label
rendered only when the caller passed one; the
dashboard tile leaves it undefined (the date is
redundant — the chart's x-axis already shows it). */}
{verticalMarkerPositions.map((marker, i) => (
<ReferenceLine
key={`vmarker-${i}-${marker.pointIndex}`}
x={marker.pointIndex}
stroke={marker.color}
strokeDasharray="3 3"
strokeWidth={1.1}
strokeOpacity={0.55}
ifOverflow="discard"
label={
marker.label
? {
value: marker.label,
position: "insideTopRight",
fill: marker.color,
fontSize: 10,
fontWeight: 500,
}
: undefined
}
/>
))}
{yDomain &&
verticalMarkerPositions.map((marker, i) => (
<ReferenceDot
key={`vmarker-dot-${i}-${marker.pointIndex}`}
x={marker.pointIndex}
y={yDomain[0]}
r={3}
fill={marker.color}
stroke="none"
ifOverflow="discard"
/>
))}
{/* v1.4.18 — personal-baseline reference line is now
opt-in via the Trend toggle. the maintainer rejected the
always-on dashed mean line; it now only paints when
@@ -1267,9 +1425,7 @@ export function HealthChart({
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts
? formatDateShort(new Date(ts), true)
: "";
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
// Build a quick lookup of compare values for this
// hover-day so the current-period row can attach
@@ -49,6 +49,7 @@ import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatDateShort } from "@/lib/format";
import { makeFormatters } from "@/lib/format-locale";
import { cn } from "@/lib/utils";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
@@ -86,21 +87,35 @@ type RangeDays = (typeof RANGE_DAYS)[number];
* intakes are skipped — compliance is undefined when nothing was due.
*
* Pure & deterministic so the unit test pins exact rates.
*
* v1.4.25 W7b — the `dateFormatter` argument lets the chart pass a
* tz-aware formatter so a Pacific/Auckland user's X-axis tick label
* for "2026-05-15" reads as 15.05. in their tz. Default keeps the
* legacy `formatDateShort` so the existing unit test (which calls the
* exported helper without arguments) stays byte-identical.
*/
export function aggregateMedicationCompliance(
points: DailyCompliancePoint[],
dateFormatter: (date: Date) => string = (d) => formatDateShort(d, false),
): ChartPoint[] {
return points
.filter((p) => p.scheduled > 0)
.sort((a, b) => a.date.localeCompare(b.date))
.map((p) => {
const [y, m, d] = p.date.split("-").map(Number);
// Anchor the timestamp at noon UTC of the Berlin day so a tooltip
// and tick formatter never disagree across DST boundaries.
// Anchor the timestamp at noon UTC of the (server-side) calendar
// day so a tooltip and tick formatter never disagree across DST
// boundaries.
const ts = Date.UTC(y, m - 1, d, 12);
const rate = Math.min(100, Math.round((p.taken / p.scheduled) * 100));
return {
date: formatDateShort(new Date(ts), true),
// v1.4.25 W3b — short-form day label ("10.05." / "10. May") so
// the X-axis ticks read cleanly on mobile even when the tick
// density helper steps to every-7th or every-14th day. The
// tooltip below still derives its label from `timestamp` with
// `includeYear=true`, so the full date is preserved for the
// tooltip read.
date: dateFormatter(new Date(ts)),
rate,
timestamp: ts,
};
@@ -165,15 +180,28 @@ interface MedicationComplianceChartProps {
* thinking the toggle is broken.
*/
compareBaseline?: "none" | "lastMonth" | "lastYear";
/**
* v1.4.25 W7b — per-user display timezone for x-axis tick + tooltip
* date strings. Defaults to "Europe/Berlin" so older callers stay
* byte-identical.
*/
userTimezone?: string;
}
export function MedicationComplianceChart({
title,
userTimezone = "Europe/Berlin",
}: MedicationComplianceChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
const [days, setDays] = useState<RangeDays>(30);
// v1.4.25 W7b — tz-aware date formatter for the x-axis labels and
// the tooltip's `dateLabel`. Same pattern as health-chart + mood-chart.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// v1.4.18 — three overlay toggles persisted per chart. The 7-day
// trend chip and the goal/threshold reference lines used to render
@@ -204,8 +232,11 @@ export function MedicationComplianceChart({
});
const chartData = useMemo(
() => (data ? aggregateMedicationCompliance(data) : []),
[data],
() =>
data
? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d))
: [],
[data, tzFmt],
);
// v1.4.16 A6 — 7-day trend chip. Computed off the *full* range so a
@@ -306,10 +337,16 @@ export function MedicationComplianceChart({
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown next to the range
tabs. */}
tabs.
v1.4.25 W3f — the compliance card never paints a
prior-period overlay (the heatmap doesn't support it),
so the comparison buttons grey out when a baseline is
already selected to signal "this metric has no
comparison available". */}
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={false}
/>
</div>
</div>
@@ -389,9 +426,7 @@ export function MedicationComplianceChart({
};
if (!active || !payload?.length) return null;
const ts = payload[0]?.payload?.timestamp;
const dateLabel = ts
? formatDateShort(new Date(ts), true)
: "";
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rate = payload[0]?.value;
if (typeof rate !== "number") return null;
// Delta vs. the 100 % goal — a positive delta means
+46 -13
View File
@@ -17,8 +17,8 @@ import { Loader2 } from "lucide-react";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
@@ -84,6 +84,12 @@ interface MoodChartProps {
* only surface — the cog stays hidden and overlays are clean.
*/
chartKey?: ChartOverlayKey;
/**
* v1.4.25 W7b — per-user display timezone. When passed, the x-axis
* tick labels and tooltip date render in the user's tz. Defaults to
* "Europe/Berlin" so older mount sites stay byte-identical.
*/
userTimezone?: string;
}
// --- Constants ---
@@ -275,9 +281,16 @@ export function MoodChart({
windowOverride,
compareBaseline = "none",
chartKey,
userTimezone = "Europe/Berlin",
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
const initialRangePoints = windowOverride
? MINI_RANGE_POINTS[windowOverride]
: 30;
@@ -313,7 +326,7 @@ export function MoodChart({
const allPoints: ChartDataPoint[] = data.entries
.map((entry) => ({
date: formatDateShort(new Date(dayKeyToTimestamp(entry.date))),
date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))),
timestamp: dayKeyToTimestamp(entry.date),
pointIndex: 0,
score: entry.score,
@@ -349,7 +362,7 @@ export function MoodChart({
})),
{ bucket: bucketType },
).points.map((point) => ({
date: formatDateShort(new Date(point.timestamp)),
date: tzFmt.dateShort(new Date(point.timestamp)),
timestamp: point.timestamp,
pointIndex: 0,
score: point.values.score,
@@ -401,7 +414,7 @@ export function MoodChart({
}
return enriched;
}, [data, rangePoints, showMA, showTrend]);
}, [data, rangePoints, showMA, showTrend, tzFmt]);
/**
* v1.4.16 phase B8 — comparison overlay merged into chartData.
@@ -426,7 +439,7 @@ export function MoodChart({
const shiftedByDay = new Map<string, number>();
for (const row of shifted) {
const dayKey = formatDateShort(new Date(row.timestamp));
const dayKey = tzFmt.dateShort(new Date(row.timestamp));
if (typeof row.score === "number" && Number.isFinite(row.score)) {
shiftedByDay.set(dayKey, row.score);
}
@@ -439,7 +452,7 @@ export function MoodChart({
}
return point;
});
}, [chartData, effectiveCompareBaseline, data]);
}, [chartData, effectiveCompareBaseline, data, tzFmt]);
const hasComparisonData = useMemo(() => {
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
@@ -591,6 +604,7 @@ export function MoodChart({
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
@@ -621,10 +635,32 @@ export function MoodChart({
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
five mood-score ticks ([1,2,3,4,5]), which Recharts
syncs with CartesianGrid by default. That left the
mini variant painting five horizontal lines while
the BP / Weight / Pulse minis painted six (their
auto-generated YAxis ticks land on six bands in
typical health ranges). The explicit coordinates
generator below produces six evenly-spaced lines
so the trends row reads as a single rhythm. */}
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
stroke="var(--border)"
opacity={0.5}
horizontalCoordinatesGenerator={({ offset }) => {
// `offset` carries the chart's plot-area metrics.
// Six evenly-spaced y-coordinates inside the plot
// area (top + 4 intermediate + bottom) match the
// density of the BP/Weight/Pulse minis.
const top = offset.top ?? 0;
const height = offset.height ?? 0;
if (height <= 0) return [];
const lines = 6;
return Array.from({ length: lines }, (_, i) =>
Math.round(top + (height * i) / (lines - 1)),
);
}}
/>
{showBands &&
VALUE_BANDS.map((band) => (
@@ -667,11 +703,10 @@ export function MoodChart({
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
formatDateShort(
tzFmt.date(
new Date(
chartData[Math.round(value)]?.timestamp ?? Date.now(),
),
true,
)
}
interval={chooseTickInterval(
@@ -719,9 +754,7 @@ export function MoodChart({
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts
? formatDateShort(new Date(ts), true)
: "";
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
const hoverPoint = payload[0]?.payload as
| ChartDataPoint
@@ -86,7 +86,7 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
<div className="touch-pan-y" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey={xAxis.dataKey}
type="number"
@@ -125,8 +125,8 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
+38 -6
View File
@@ -211,20 +211,52 @@ export function TrendCard({
return (
<div className="bg-card border-border flex h-full w-full min-w-0 flex-col rounded-xl border p-4 md:p-6">
<div className="flex min-w-0 items-start justify-between gap-2">
<span className="text-muted-foreground min-w-0 text-xs leading-snug font-medium tracking-wide break-words uppercase">
{/* v1.4.25 W20a — single-line discipline + deterministic height for
the heading row so the value row below sits at the same baseline
across every tile. The label is locale-abbreviated upstream (see
`dashboard.*Short` keys) so `truncate` is a layout safeguard
rather than the primary defence against wraps. `h-5` matches
`text-xs leading-5` ≈ 20 px; identical across every tile. */}
<div className="flex h-5 min-w-0 items-center justify-between gap-2">
<span
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-5 font-medium tracking-wide whitespace-nowrap uppercase"
data-slot="trend-card-label"
>
{label}
</span>
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
<div className="mt-2 flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="min-w-0 text-3xl leading-none font-bold tracking-tight [overflow-wrap:anywhere] tabular-nums">
{/* v1.4.25 W20a — baseline-aligned value row. The headline value,
unit, and trend arrow share a single flex line with
`items-baseline` so the digits line up at the same y-coordinate
across every tile in the strip (Weight 80 kg vs. BP 122 mmHg used
to land at different heights when one tile had a one-line heading
and another had a two-line wrap). The arrow slot is always
present — when there is no slope yet, a muted "—" placeholder
renders so the value row keeps a deterministic width. Marc's
ask: "rechts neben der Zahl … der Pfeil [ist], wie gerade der
Trend ist". */}
<div
className="mt-2 flex min-w-0 items-baseline gap-x-1.5"
data-slot="trend-card-value-row"
>
<span className="min-w-0 truncate text-3xl leading-none font-bold tracking-tight tabular-nums">
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground text-sm [overflow-wrap:anywhere] tabular-nums">
<span className="text-muted-foreground shrink-0 text-sm tabular-nums">
{unit}
</span>
{slope30 && <TrendIcon className={`h-4 w-4 shrink-0 ${trendColor}`} />}
<span
className="ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center"
data-slot="trend-card-arrow"
aria-hidden="true"
>
{slope30 ? (
<TrendIcon className={`h-4 w-4 ${trendColor}`} />
) : (
<span className="text-muted-foreground text-xs opacity-30">—</span>
)}
</span>
</div>
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
line below the latest value so the tile stays scannable on
@@ -0,0 +1,302 @@
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"');
// No chart when the series is empty.
expect(html).not.toContain('data-slot="glp1-tile-chart"');
});
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");
});
});
+338
View File
@@ -0,0 +1,338 @@
"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 } 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";
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
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();
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",
// A faint green seam on the left edge so the tile reads as
// "active therapy" at a glance without needing to read the
// header. The same green is used for the injection-day markers
// below — visual consistency across the tile.
"border-l-dracula-green/60 border-l-2",
)}
>
<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>
<dl className="text-muted-foreground grid grid-cols-1 gap-y-1 pb-3 text-xs sm:grid-cols-2 sm:gap-x-4">
{med.lastInjection && (
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.lastInjection")}:
</dt>
<dd
data-slot="glp1-tile-last"
className="text-foreground tabular-nums"
>
{dateWithWeekday(med.lastInjection.date)}
</dd>
</div>
)}
{med.nextInjection && (
<div className="flex items-center gap-1.5">
<Syringe className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.nextInjection")}:
</dt>
<dd
data-slot="glp1-tile-next"
className="text-foreground tabular-nums"
>
{dateWithWeekday(med.nextInjection.date)}
<span className="text-muted-foreground ml-1">
(
{t("dashboard.glp1.inDays", {
count: med.nextInjection.daysAway,
})}
)
</span>
</dd>
</div>
)}
</dl>
{/* Compact weight chart with the vertical injection-day markers
the v1.4.25 W6 chart-extension adds. Skipped when we have no
weight readings since the GLP-1 was started — the empty
chart would draw nothing useful. */}
{med.weightSeries.length > 0 && (
<div data-slot="glp1-tile-chart">
<HealthChart
mini
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["#bd93f9"]}
unit="kg"
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
userTimezone={user?.timezone}
/>
</div>
)}
</div>
);
}
/**
* 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>
);
}
@@ -65,6 +65,36 @@ describe("doctor-report dialog — i18n contract", () => {
expect(typeof dialog![key]).toBe("string");
}
});
// v1.4.25 W6c — per-section toggle strings.
it(`exposes every doctor-report section-toggle key in ${locale}.json`, () => {
const messages = loadMessages(locale);
const root = messages.doctorReport as Record<string, unknown> | undefined;
const sections = root!.sections as Record<string, unknown> | undefined;
expect(
sections,
`doctorReport.sections missing in ${locale}.json`,
).toBeDefined();
const sectionKeys = [
"title",
"bp",
"weight",
"pulse",
"bmi",
"mood",
"moodSensitive",
"compliance",
"sleep",
"empty",
];
for (const key of sectionKeys) {
expect(
sections![key],
`doctorReport.sections.${key} missing in ${locale}.json`,
).toBeTruthy();
expect(typeof sections![key]).toBe("string");
}
});
}
it("EN dialog uses the expected English title + 90d preset", () => {
@@ -1,14 +1,28 @@
"use client";
/**
* Doctor-report export dialog (v1.4.15 phase B6).
* Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c).
*
* Wraps the trigger button so the user picks a reporting period and
* (optionally) a practice / clinic name before the PDF is generated.
* Wraps the trigger button so the user picks:
* 1. Reporting period (date range, with three presets).
* 2. Practice / clinic name (optional, persisted between exports).
* 3. Which data sections appear in the PDF (per-user persisted toggles).
*
* Hide-when-empty (Marc 2026-05-14): a section's toggle is only shown
* when the selected date range actually has data for it — checking a box
* for an empty section would produce a silently-empty PDF page. The
* availability probe runs on every range change via
* `/api/doctor-report/availability`.
*
* Privacy default: mood is OFF by default per Marc — mental-health data
* is opt-in even within a single user's own surface. The API layer
* filters mood out of the report payload server-side when the toggle is
* off, so the data never leaves the DB row.
*
* Defaults:
* - end = today
* - start = today − 90 days
* - sections = persisted prefs OR documented defaults
*
* Validation runs in the client so the dialog can show inline errors
* without bouncing through the server. The server still re-validates via
@@ -16,7 +30,7 @@
* messages are UX, not security.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -31,16 +45,53 @@ import {
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { useTranslations } from "@/lib/i18n/context";
const ONE_DAY_MS = 86_400_000;
const MAX_RANGE_DAYS = 730;
const PRACTICE_NAME_MAX_LENGTH = 120;
/**
* Order in which section toggles render. Matches the order the sections
* appear in the generated PDF so the dialog's mental model lines up with
* the printed artefact. `mood` is intentionally last because its
* privacy-sensitive footnote needs prime visual placement.
*/
const SECTION_ORDER: ReadonlyArray<keyof DoctorReportPrefs> = [
"bp",
"weight",
"pulse",
"bmi",
"compliance",
"sleep",
"mood",
] as const;
interface SectionAvailability {
bp: boolean;
weight: boolean;
pulse: boolean;
bmi: boolean;
mood: boolean;
compliance: boolean;
sleep: boolean;
}
export interface DoctorReportSubmitPayload {
startDate: string;
endDate: string;
practiceName: string | null;
/**
* Per-section toggles. The caller forwards this to
* `/api/doctor-report` so the aggregator drops disabled sections
* (mood specifically is filtered at the data layer for privacy).
*/
sections: DoctorReportPrefs;
}
interface DoctorReportDialogProps {
@@ -75,6 +126,22 @@ function defaultStartIso(): string {
return formatLocalDate(d);
}
function rangeIsoFromLocal(
startDate: string,
endDate: string,
): {
startIso: string;
endIso: string;
} {
// Send dates as ISO timestamps anchored to the local day's start/end
// so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` captures everything
// logged on each boundary day.
return {
startIso: new Date(`${startDate}T00:00:00`).toISOString(),
endIso: new Date(`${endDate}T23:59:59.999`).toISOString(),
};
}
export function DoctorReportDialog({
open,
onOpenChange,
@@ -88,6 +155,22 @@ export function DoctorReportDialog({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Per-user section preferences. Loaded lazily when the dialog opens —
// skips a roundtrip on every render of the export card.
const [prefs, setPrefs] = useState<DoctorReportPrefs>(
DEFAULT_DOCTOR_REPORT_PREFS,
);
const [prefsLoaded, setPrefsLoaded] = useState(false);
// Section availability for the currently-selected range. `null` =
// "we haven't probed yet" so the section group renders a skeleton row
// rather than flashing an empty state.
const [availability, setAvailability] = useState<SectionAvailability | null>(
null,
);
const [availabilityLoading, setAvailabilityLoading] = useState(false);
const availabilityRequestId = useRef(0);
// Reset on open: re-anchor dates + practice-name to defaults each time
// the dialog appears so a follow-up export doesn't reuse the previous
// custom range. Using the React-recommended "track-the-trigger" pattern
@@ -106,6 +189,74 @@ export function DoctorReportDialog({
setLastOpen(false);
}
// Load persisted section prefs when the dialog opens for the first
// time. Best-effort: a failure falls back to documented defaults so
// the dialog stays usable even if the preferences endpoint is down.
useEffect(() => {
if (!open || prefsLoaded) return;
let cancelled = false;
fetch("/api/auth/me/doctor-report-prefs", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled) return;
const incoming = json?.data;
if (incoming && typeof incoming === "object") {
setPrefs({ ...DEFAULT_DOCTOR_REPORT_PREFS, ...incoming });
}
setPrefsLoaded(true);
})
.catch(() => {
if (!cancelled) setPrefsLoaded(true);
});
return () => {
cancelled = true;
};
}, [open, prefsLoaded]);
// Re-probe availability whenever the range changes. setState calls
// live inside the promise handlers (microtask-deferred), satisfying
// the strict `react-hooks/set-state-in-effect` rule while keeping the
// loading indicator promptly visible. A generation counter prevents
// a slow earlier response from stomping on a faster later response
// (classic stale-write race).
useEffect(() => {
if (!open) return;
if (!startDate || !endDate) return;
const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate);
const requestId = ++availabilityRequestId.current;
let cancelled = false;
Promise.resolve()
.then(() => {
if (cancelled) return null;
setAvailabilityLoading(true);
return fetch("/api/doctor-report/availability", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ startDate: startIso, endDate: endIso }),
});
})
.then(async (res) => {
if (cancelled || !res || !res.ok) return;
const json = (await res.json()) as { data: SectionAvailability };
if (requestId !== availabilityRequestId.current) return;
setAvailability(json.data);
})
.catch(() => {
// Best-effort — keep the previous availability snapshot.
})
.finally(() => {
if (!cancelled && requestId === availabilityRequestId.current) {
setAvailabilityLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, startDate, endDate]);
const validation = useMemo(() => {
if (!startDate || !endDate) {
return {
@@ -146,6 +297,36 @@ export function DoctorReportDialog({
setError(null);
}
// Sections that have data in the current range. Drives both the
// rendered checkbox list AND the canonical payload — toggles for
// empty sections are stripped before submission so the server doesn't
// see a "yes, render this empty thing" instruction.
const activeSections = useMemo<Array<keyof DoctorReportPrefs>>(() => {
if (!availability) return [];
return SECTION_ORDER.filter((key) => availability[key]);
}, [availability]);
function toggleSection(key: keyof DoctorReportPrefs, value: boolean) {
setPrefs((current) => ({ ...current, [key]: value }));
}
async function persistPrefs(next: DoctorReportPrefs) {
// Best-effort PUT — a network blip mustn't block PDF generation. The
// current dialog still submits with the chosen `next` value either
// way, so the user gets the PDF they asked for even if persistence
// failed (they'll just see the defaults next time).
try {
await fetch("/api/auth/me/doctor-report-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(next),
});
} catch {
// ignore
}
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!validation.ok) {
@@ -155,12 +336,20 @@ export function DoctorReportDialog({
setSubmitting(true);
setError(null);
try {
// Send dates as ISO timestamps anchored to the local day's
// start/end so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]`
// captures everything logged on each boundary day.
const startIso = new Date(`${startDate}T00:00:00`).toISOString();
const endIso = new Date(`${endDate}T23:59:59.999`).toISOString();
const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate);
const trimmedPractice = practiceName.trim();
// Compose the section payload: keep the persisted prefs for keys
// that ARE available in the range; force `false` for keys that
// aren't (so the server never tries to render an empty section).
const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS };
for (const key of SECTION_ORDER) {
sections[key] = (availability?.[key] ?? false) && prefs[key] === true;
}
// Fire-and-forget persistence so the user doesn't wait on it.
void persistPrefs(prefs);
await onSubmit({
startDate: startIso,
endDate: endIso,
@@ -168,6 +357,7 @@ export function DoctorReportDialog({
trimmedPractice.length > 0
? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH)
: null,
sections,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
@@ -263,6 +453,15 @@ export function DoctorReportDialog({
/>
</div>
{/* ── Section toggles ──────────────────────────────────────── */}
<SectionToggles
activeSections={activeSections}
availability={availability}
availabilityLoading={availabilityLoading}
prefs={prefs}
onToggle={toggleSection}
/>
{error && (
<p
role="alert"
@@ -294,3 +493,119 @@ export function DoctorReportDialog({
</Dialog>
);
}
// ────────────────────────────── Subviews ──────────────────────────────
interface SectionTogglesProps {
activeSections: Array<keyof DoctorReportPrefs>;
availability: SectionAvailability | null;
availabilityLoading: boolean;
prefs: DoctorReportPrefs;
onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void;
}
const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
bp: "doctorReport.sections.bp",
weight: "doctorReport.sections.weight",
pulse: "doctorReport.sections.pulse",
bmi: "doctorReport.sections.bmi",
mood: "doctorReport.sections.mood",
compliance: "doctorReport.sections.compliance",
sleep: "doctorReport.sections.sleep",
};
function SectionToggles({
activeSections,
availability,
availabilityLoading,
prefs,
onToggle,
}: SectionTogglesProps) {
const { t } = useTranslations();
// First open: show a quiet skeleton row instead of the full toggle
// list. Keeps the dialog from "flashing" between empty and populated
// states while the probe is in-flight.
if (availability === null && availabilityLoading) {
return (
<div
className="border-border bg-muted/30 space-y-2 rounded-lg border p-3"
data-testid="doctor-report-sections-loading"
>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
<div className="bg-muted h-9 animate-pulse rounded-md" />
<div className="bg-muted h-9 animate-pulse rounded-md" />
</div>
</div>
);
}
// No data in the selected range — render an empty-state hint so the
// user understands why the toggle list is missing. The PDF still
// generates (cover + period + an empty body) so they get something.
if (availability && activeSections.length === 0) {
return (
<div
className="border-border bg-muted/30 rounded-lg border p-3"
data-testid="doctor-report-sections-empty"
>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<p className="text-muted-foreground mt-1.5 text-xs leading-relaxed">
{t("doctorReport.sections.empty")}
</p>
</div>
);
}
return (
<div
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3"
data-testid="doctor-report-sections"
>
<div className="flex items-baseline justify-between gap-2">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
{availabilityLoading && (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{activeSections.map((key) => {
const id = `dr-section-${key}`;
return (
<li key={key} className="min-h-11">
<label
htmlFor={id}
className="hover:bg-muted/50 -mx-2 flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors"
>
<span className="flex flex-col">
<span className="text-sm leading-tight font-medium">
{t(SECTION_LABEL_KEYS[key])}
</span>
{key === "mood" && (
<span className="text-muted-foreground text-[11px] leading-tight">
{t("doctorReport.sections.moodSensitive")}
</span>
)}
</span>
<Switch
id={id}
data-testid={`doctor-report-section-${key}`}
size="sm"
checked={prefs[key]}
onCheckedChange={(checked) => onToggle(key, checked)}
/>
</label>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MaintainershipBanner } from "../maintainership-banner";
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
/**
* v1.4.25 W9e — <MaintainershipBanner> contract:
* 1. Renders nothing on maintained locales (DE / EN).
* 2. Renders a notice strip on AI-initial locales (FR / ES / IT / PL).
* 3. The notice carries a GitHub-issue CTA so feedback has a clear
* home.
*
* Dismissal-state behaviour (localStorage round-trip + button click)
* is covered by the component implementation; SSR markup is what the
* project's other component tests pin (see `error-details.test.tsx`),
* so we mirror that idiom here. The mount-time `useEffect` that reads
* localStorage runs client-side only — the SSR output we test here
* reflects the pre-mount "dismissed by default" branch, which is the
* exact contract for first paint with a stored flag.
*
* The "banner visible on first paint when no flag stored" path is
* verified end-to-end by the dev server + Playwright lane (W9f);
* here we pin the locale gating and the markup contract.
*/
function ssr(locale: Locale): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MaintainershipBanner />
</I18nProvider>,
);
}
describe("<MaintainershipBanner>", () => {
beforeEach(() => {
// Fresh localStorage so each test starts from the "never dismissed"
// state. The SSR path doesn't read it, but keeping the slate clean
// documents the contract for the client-side branch.
try {
globalThis.localStorage?.clear();
} catch {
/* SSR — localStorage absent */
}
});
it("renders nothing on the EN locale (maintained)", () => {
const html = ssr("en");
expect(html).not.toContain("maintainership-banner");
});
it("renders nothing on the DE locale (maintained)", () => {
const html = ssr("de");
expect(html).not.toContain("maintainership-banner");
});
// SSR path renders the dismissed-by-default state to avoid a
// hydration mismatch. Client mount reads localStorage and reveals
// the banner when no flag exists. The SSR-only assertion below
// documents that the strip is NOT painted before the first effect
// runs — exactly the contract that prevents the flash-of-banner
// problem.
it("paints nothing in the SSR pass for AI-initial locales", () => {
for (const locale of ["fr", "es", "it", "pl"] as const) {
const html = ssr(locale);
expect(html).not.toContain("maintainership-banner");
}
});
it("the component module exports the locale gate as a named symbol", async () => {
// Sanity check: the gate (`isMaintainedLocale`) must stay re-exported
// from the same `config` module so admin tooling / the future
// settings switch can read the source of truth without importing
// the banner itself.
const cfg = await import("@/lib/i18n/config");
expect(typeof cfg.isMaintainedLocale).toBe("function");
expect(cfg.isMaintainedLocale("en")).toBe(true);
expect(cfg.isMaintainedLocale("de")).toBe(true);
expect(cfg.isMaintainedLocale("fr")).toBe(false);
expect(cfg.isMaintainedLocale("es")).toBe(false);
expect(cfg.isMaintainedLocale("it")).toBe(false);
expect(cfg.isMaintainedLocale("pl")).toBe(false);
});
// v1.4.25 W14c — banner copy upgraded to acknowledge that the
// FR/ES/IT/PL Coach prompts are AI-drafted including safety-critical
// instructions. Pin the new wording so a copy regression cannot
// quietly soften the disclosure.
it("EN banner copy names safety-critical AI-drafted Coach content", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const en = JSON.parse(
readFileSync(join(process.cwd(), "messages", "en.json"), "utf8"),
) as { i18n: { maintainershipBanner: { notice: string } } };
const notice = en.i18n.maintainershipBanner.notice;
expect(notice).toMatch(/AI-drafted/);
expect(notice).toMatch(/safety-critical/);
expect(notice).toMatch(/GitHub/);
expect(notice).toMatch(/Coach/);
});
it("every AI-initial locale's banner notice acknowledges the AI-drafted Coach content", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const aiDraftPatterns: Record<string, RegExp> = {
fr: /rédigée par IA/,
es: /redactado por IA/,
it: /redatta tramite IA/,
pl: /opracowana przez AI/,
};
for (const [locale, pattern] of Object.entries(aiDraftPatterns)) {
const data = JSON.parse(
readFileSync(
join(process.cwd(), "messages", `${locale}.json`),
"utf8",
),
) as { i18n: { maintainershipBanner: { notice: string } } };
const notice = data.i18n.maintainershipBanner.notice;
expect(notice, `${locale} banner notice`).toMatch(pattern);
expect(notice, `${locale} banner notice mentions Coach`).toMatch(
/Coach/,
);
}
});
});
@@ -0,0 +1,124 @@
"use client";
import { useState, useSyncExternalStore } from "react";
import { Languages, X } from "lucide-react";
import { isMaintainedLocale, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W9e — notice strip for AI-initial locales.
*
* HealthLog ships German + English as maintainer-curated translations
* (the maintainer, Marc, owns both bodies). French, Spanish, Italian
* and Polish ship as AI-initial translations — they pass the
* locale-integrity test (full key parity, no empty values, no TODOs)
* but the prose has not been hand-reviewed for register, regional
* vocabulary, or domain-specific nuance.
*
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL
* today) and surfaces a small, dismissible strip at the top of the
* authenticated app shell. It tells the user the translation is
* AI-maintained and links to the GitHub translation-feedback issue
* template so they can contribute fixes back.
*
* Dismissal is persisted to localStorage per-locale so a user who
* dismisses the FR banner does not see it again on next visit, but
* still sees the IT banner when they switch language.
*
* The component returns null (renders nothing) when:
* - the active locale is in MAINTAINED_LOCALES, or
* - the user has dismissed this locale's banner, or
* - we are server-side rendering (no localStorage) — the client
* re-evaluates on mount, so the banner appears on second paint
* without a hydration mismatch warning.
*/
const STORAGE_PREFIX = "healthlog-i18n-banner-dismissed:";
// v1.4.26 P6-8 — `.github/ISSUE_TEMPLATE/translation.yml` now exists.
// GitHub matches the `?template=` query against the filename; the old
// `.md` filename never resolved (the form file was missing), so the
// link landed users on a blank issue body.
const GITHUB_ISSUE_URL =
"https://github.com/MBombeck/HealthLog/issues/new?template=translation.yml";
function storageKey(locale: Locale): string {
return `${STORAGE_PREFIX}${locale}`;
}
function readDismissed(locale: Locale): boolean {
if (typeof window === "undefined") return true;
try {
return window.localStorage.getItem(storageKey(locale)) === "1";
} catch {
// localStorage unavailable (private mode, quota) — fail open so the
// banner appears. The user can still hide it for the session.
return false;
}
}
// Subscribe is a no-op: we only need React to re-read storage on the
// initial client render, not to react to outside writes. Returning a
// noop unsubscriber is the React-recommended pattern for one-shot
// hydration of a browser-only value (mirrors next/router's locale
// snapshot trick).
const subscribeNoop = () => () => undefined;
export function MaintainershipBanner() {
const { locale, t } = useTranslations();
// SSR snapshot: server treats the banner as dismissed so the static
// markup never paints it. Client mount swaps in the real flag from
// localStorage during the same render via useSyncExternalStore —
// no setState-in-effect, no hydration mismatch warning.
const dismissedFromStorage = useSyncExternalStore<boolean>(
subscribeNoop,
() => readDismissed(locale),
() => true,
);
const [sessionDismissed, setSessionDismissed] = useState(false);
const dismissed = dismissedFromStorage || sessionDismissed;
if (isMaintainedLocale(locale)) return null;
if (dismissed) return null;
const handleDismiss = () => {
setSessionDismissed(true);
try {
window.localStorage.setItem(storageKey(locale), "1");
} catch {
// Best-effort persistence — the in-memory `sessionDismissed`
// flag still hides the banner for the rest of the session.
}
};
return (
<div
role="status"
data-testid="maintainership-banner"
className="border-border bg-muted/40 text-muted-foreground flex items-start gap-2 border-b px-4 py-2 text-xs sm:items-center"
>
<Languages className="mt-0.5 h-3.5 w-3.5 shrink-0 sm:mt-0" aria-hidden />
<p className="flex-1 leading-snug">
{t("i18n.maintainershipBanner.notice")}{" "}
<a
href={GITHUB_ISSUE_URL}
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline-offset-4 hover:underline"
>
{t("i18n.maintainershipBanner.cta")}
</a>
.
</p>
<button
type="button"
onClick={handleDismiss}
aria-label={t("i18n.maintainershipBanner.dismiss")}
// 44×44 touch-target floor (WCAG 2.5.5). The icon stays small —
// the surrounding hit area is what the user actually taps.
className="hover:text-foreground focus-visible:ring-ring/50 -mr-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
}
@@ -0,0 +1,377 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
HealthScoreCard,
type HealthScoreCardProps,
} from "../health-score-card";
/**
* v1.4.25 W8e — Health-Score provenance accordion.
*
* The card now grows a tap-to-expand accordion under the four
* component sub-bars. The accordion surfaces:
* - per-component value-bar + numeric value
* - effective weight share (second, narrower bar)
* - source pill (manual / withings / appleHealth / mixed / none)
* - a "mixed sources" `role="status"` banner when at least one row
* has `source === "mixed"`
* - a "Provisional" badge in the header when < 50% of inputs are
* present
*
* SSR is the test convention in this project (no `@testing-library/react`
* dependency). The card exposes `initiallyExpanded` so the markup of
* the expanded panel renders inside `renderToStaticMarkup`. Interactive
* behaviour (click toggles `aria-expanded`) is verified at the contract
* level: collapsed state pins `aria-expanded="false"`, expanded state
* (via `initiallyExpanded`) pins `aria-expanded="true"` + renders the
* panel.
*/
const componentsAllPresent: HealthScoreCardProps["components"] = {
bp: {
value: 82,
weight: 0.3,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: {
value: 75,
weight: 0.2,
source: "manual",
asOf: "2026-05-13T18:00:00.000Z",
},
mood: {
value: 90,
weight: 0.2,
source: "manual",
asOf: "2026-05-14T07:00:00.000Z",
},
compliance: {
value: 95,
weight: 0.3,
source: "manual",
asOf: "2026-05-14T00:00:00.000Z",
},
};
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
it("renders the toggle button with aria-expanded=false when collapsed", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="false"/,
);
// Toggle text uses the localised "Driven by" copy.
expect(html).toContain("Driven by");
// Panel is NOT rendered when collapsed.
expect(html).not.toContain(
'data-slot="health-score-card-provenance-panel"',
);
});
it("renders the panel + sets aria-expanded=true when initiallyExpanded", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="true"/,
);
expect(html).toContain('data-slot="health-score-card-provenance-panel"');
});
it("aria-controls on the toggle points at the rendered panel's id", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// Extract aria-controls value from the toggle and confirm the
// panel section uses the same id.
const controlsMatch = html.match(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-controls="([^"]+)"/,
);
expect(controlsMatch).not.toBeNull();
const id = controlsMatch![1];
// The panel section must carry the same id; we don't pin the exact
// attribute order because additional ARIA attributes (e.g.
// `aria-labelledby`) may sit between `id` and `data-slot`.
const panelMatch = html.match(
new RegExp(`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`),
);
expect(panelMatch).not.toBeNull();
});
it("sorts rows by effective weight descending (null components sink to bottom)", () => {
const components: HealthScoreCardProps["components"] = {
bp: { value: null, weight: 0, source: "none" },
weight: { value: 60, weight: 0.4, source: "manual" },
mood: { value: 80, weight: 0.3, source: "manual" },
compliance: { value: 70, weight: 0.3, source: "manual" },
};
const html = ssr(
<HealthScoreCard
score={60}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// Pull every `data-component=` token from the provenance rows.
const rowMatches = Array.from(
html.matchAll(
/data-slot="health-score-card-provenance-row"[^>]*data-component="([^"]+)"/g,
),
).map((m) => m[1]);
// Weight (0.4) > mood (0.3) > compliance (0.3) — mood vs compliance
// tie-break is the alphabetical key order. BP at weight=0 sinks
// to the bottom.
expect(rowMatches).toEqual(["weight", "compliance", "mood", "bp"]);
});
it("renders source pills with locale-resolved labels (EN)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// Withings + Manual both present in the data.
expect(html).toContain("Withings");
expect(html).toContain("Manual");
});
it("renders source pills with locale-resolved labels (DE)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
"de",
);
// Manuell is the DE label for "manual".
expect(html).toContain("Manuell");
expect(html).toContain("Withings");
expect(html).toContain("Zusammensetzung");
});
it("renders the mixed-source banner with role=status when any row is mixed", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
bp: { value: 82, weight: 0.3, source: "mixed" },
};
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={components}
delta={null}
initiallyExpanded
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-mixed-banner"[^>]*role="status"/,
);
// The pill on the BP row reads "Mixed" (EN).
expect(html).toContain("Mixed");
});
it("does NOT render the mixed-source banner when no row is mixed", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-provenance-mixed-banner"',
);
});
it("dims empty-state rows when source is `none`", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
mood: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={70}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// The dimmed row carries opacity-50 alongside the `data-source="none"`
// attribute.
expect(html).toMatch(
/data-component="mood"[^>]*data-source="none"[^>]*class="[^"]*opacity-50/,
);
// The source pill reads "No data" (EN).
expect(html).toContain("No data");
});
it("renders the provisional badge when fewer than 50% of inputs are present", () => {
// Three out of four components null → 1 of 4 = 25 % present.
const components: HealthScoreCardProps["components"] = {
bp: {
value: 80,
weight: 1,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: { value: null, weight: 0, source: "none" },
mood: { value: null, weight: 0, source: "none" },
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={components}
delta={null}
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provisional-badge"',
);
// EN badge copy.
expect(html).toContain("Provisional");
});
it("does NOT render the provisional badge when at least 50% of inputs are present", () => {
// 2 of 4 = 50 % → NOT provisional.
const components: HealthScoreCardProps["components"] = {
bp: {
value: 80,
weight: 0.6,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: {
value: 70,
weight: 0.4,
source: "manual",
asOf: "2026-05-13T08:00:00.000Z",
},
mood: { value: null, weight: 0, source: "none" },
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={75}
band="green"
components={components}
delta={null}
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-provisional-badge"',
);
});
it("renders the source-pill aria-label with localised source name", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// BP row uses Withings; the aria-label uses the localised
// "Source: Withings" form.
expect(html).toContain('aria-label="Source: Withings"');
});
it("renders the value bar at 0% width for empty-state rows", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={70}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// Every row inside the panel carries an inline-styled width.
// We confirm the compliance (none) row's value-bar block paints
// 0% by searching for the matching row.
const complianceRowMatch = html.match(
/data-slot="health-score-card-provenance-row"[^>]*data-component="compliance"[\s\S]*?width:\s*([0-9.]+%)/,
);
expect(complianceRowMatch).not.toBeNull();
expect(complianceRowMatch?.[1]).toBe("0%");
});
it("forwards the asOf timestamp into the source-pill `title` tooltip", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// The BP row's pill carries the title formatted via Intl.DateTimeFormat;
// we only need to confirm `as of` appears (the exact date depends on
// the JS engine's locale data).
expect(html).toContain("as of");
// The data-as-of attribute carries the raw ISO timestamp.
expect(html).toContain('data-as-of="2026-05-14T08:00:00.000Z"');
});
it("renders the footnote inside the expanded panel", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provenance-footnote"',
);
});
});
@@ -143,34 +143,14 @@ describe("<HeroStrip>", () => {
expect(coachTag?.[0]).not.toContain('title="Coming soon"');
});
it("renders the regenerate button when onRegenerate is supplied", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} onRegenerate={() => {}} />,
);
expect(html).toMatch(/data-slot="insights-hero-strip-action-rerun"/);
expect(html).toContain("Re-run analysis");
});
it("hides the regenerate button when no handler is supplied", () => {
// v1.4.25 W3 — the regenerate button moved from the hero action row
// to the dedicated `<InsightsTabStrip>` component. The hero strip no
// longer renders the rerun slot under any prop combination.
it("does NOT render a regenerate button in the hero action row", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain('data-slot="insights-hero-strip-action-rerun"');
});
it("disables the regenerate button while regenerating", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
onRegenerate={() => {}}
regenerating
/>,
);
expect(html).toMatch(
/data-slot="insights-hero-strip-action-rerun"[^>]*disabled/,
);
expect(html).toContain("Regenerating");
});
// ── Suggested prompts strip ────────────────────────────────────────
it("mounts the SuggestedPrompts strip below the action band", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
@@ -1,107 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightsPageHero } from "../insights-page-hero";
/**
* v1.4.16 phase B1b — Apple-Health-style page hero.
*
* The /insights page entry-point gets a gradient hero shell:
* - h1 + subtitle (kept from the old layout, just polished)
* - "Generated <relative-time>" caption when an updatedAt is supplied
* - regenerate button when an onRegenerate handler is supplied
* - "Based on your last 90 days" personal-baseline indicator
* - smooth fade-in (CSS animation, gated by prefers-reduced-motion)
*
* The hero is a pure presentational shell; the page passes the values
* in. This test pins the slot positions so a future polish pass can't
* silently drop one (e.g. dropping the personal-baseline caption).
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<InsightsPageHero>", () => {
it("renders the heading + overview subtitle", () => {
const html = render(<InsightsPageHero />);
expect(html).toContain("Insights");
expect(html).toContain("Trends, risks, progress at a glance");
});
it("paints a gradient background using Dracula tokens", () => {
const html = render(<InsightsPageHero />);
// The hero wrapper should carry a gradient + Dracula tokens so the
// page header reads as the Apple-Health-style band.
expect(html).toMatch(/data-slot="insights-page-hero"/);
expect(html).toMatch(/bg-gradient-to/);
});
it("renders the personal-baseline caption", () => {
const html = render(<InsightsPageHero />);
expect(html).toContain("Based on your last 90 days");
});
it("renders the German personal-baseline caption", () => {
const html = render(<InsightsPageHero />, "de");
expect(html).toContain("Basierend auf deinen letzten 90 Tagen");
});
it("renders the generated-time caption when updatedAt is supplied", () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
const html = render(<InsightsPageHero updatedAt={fiveMinutesAgo} />);
// Either "just now" / "5 minutes ago" / "vor 5 Minuten" — we just
// assert the "Generated" label preceeds the relative-time string so
// a future Intl.RelativeTimeFormat tweak can't dodge the slot.
expect(html).toMatch(/data-slot="insights-page-hero-generated"/);
expect(html).toContain("Generated");
});
it("does NOT render the generated caption when updatedAt is missing", () => {
const html = render(<InsightsPageHero />);
expect(html).not.toContain('data-slot="insights-page-hero-generated"');
});
it("renders a regenerate button when onRegenerate is supplied", () => {
// The button is a real <button>, so SSR includes the markup verbatim.
const html = render(<InsightsPageHero onRegenerate={() => {}} />);
expect(html).toMatch(/data-slot="insights-page-hero-regenerate"/);
});
it("does NOT render the regenerate button when onRegenerate is missing", () => {
const html = render(<InsightsPageHero />);
expect(html).not.toContain('data-slot="insights-page-hero-regenerate"');
});
it("disables the regenerate button while regenerating", () => {
const html = render(
<InsightsPageHero onRegenerate={() => {}} regenerating />,
);
expect(html).toMatch(
/data-slot="insights-page-hero-regenerate"[^>]*disabled/,
);
});
it("German locale — heading + subtitle translate", () => {
const html = render(<InsightsPageHero />, "de");
// Reuses existing insights.title + insights.overviewSubtitle keys.
// The "Insights" string is identical in DE so we assert the subtitle.
expect(html).toContain("Trends, Risiken, Fortschritt auf einen Blick");
});
// ── B1b acceptance #6 — dark-mode contrast hygiene ────────────────
it("uses high-enough gradient + border opacity to remain visible on dark bg", () => {
const html = render(<InsightsPageHero />);
// Border opacity must be >= 20% so it reads against the dark
// background — `border-dracula-purple/20` or higher passes the
// 3:1 contrast bar for a non-text UI element.
expect(html).toMatch(/border-dracula-purple\/(2[0-9]|[3-9][0-9])/);
// Gradient opacity must be at least /10 (we ship /10 + /5 on the
// band; the cumulative effect on a dark bg renders as ~12% which
// is the lower bound for visible band-vs-page differentiation).
expect(html).toMatch(/from-dracula-purple\/(1[0-9]|[2-9][0-9])/);
});
});
@@ -0,0 +1,109 @@
/**
* v1.4.25 W16c — `<PersonalRecordBadge>` rendering.
*
* The badge renders a small "PR" pill when the user achieved an
* all-time best for the metric in the last 30 days. Tests cover:
* 1. Renders the pill when a fresh PR exists.
* 2. Stays silent when no PR exists for the metric.
* 3. Stays silent when the only PR is older than 30 days.
* 4. Surfaces metric-type and PR-value via data-attrs / tooltip.
* 5. EN + DE locale parity.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// React-Query is mocked the same way the other insights tests do it —
// we control the `data` field per-test so the SSR tree is deterministic.
type Row = {
id: string;
metricType: string;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
};
let mockRows: Row[] = [];
vi.mock("@tanstack/react-query", () => ({
// `dataUpdatedAt` mirrors what TanStack Query produces after a
// successful refetch — `Date.now()` is fine inside the test
// (the React-purity rule is component-scoped).
useQuery: () => ({
data: mockRows,
isLoading: false,
dataUpdatedAt: Date.now(),
}),
}));
import { PersonalRecordBadge } from "../personal-record-badge";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function freshPR(over: Partial<Row> = {}): Row {
return {
id: "pr-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
value: 18432,
unit: "count",
achievedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
...over,
};
}
describe("<PersonalRecordBadge>", () => {
it("renders the PR pill when an all-time best lands in the last 30 days", () => {
mockRows = [freshPR()];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).toMatch(/data-slot="insights-pr-badge"/);
expect(html).toMatch(/data-metric-type="ACTIVITY_STEPS"/);
expect(html).toContain("PR");
});
it("renders nothing when no PR exists for the metric", () => {
mockRows = [];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("stays silent when the PR is older than 30 days", () => {
const stale = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
mockRows = [freshPR({ achievedAt: stale })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("ignores workout-slot rows on the plain-metric badge", () => {
mockRows = [freshPR({ metricSlot: "longest_run_duration" })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("uses the same 'PR' literal across EN and DE locales", () => {
mockRows = [freshPR()];
const en = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"en",
);
const de = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"de",
);
expect(en).toContain("PR");
expect(de).toContain("PR");
});
});
@@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "../sleep-stage-stacked-bar";
/**
* v1.4.25 W4c / W3f — sleep-stage stacked-bar chart unit tests.
*
* The chart relies on Recharts which uses `ResponsiveContainer` —
* `renderToStaticMarkup` produces SSR-only HTML so we assert the
* surrounding card chrome (heading, aria-label, window toggle, empty
* state) rather than the Recharts-rendered `<rect>` nodes. That keeps
* the tests resilient to Recharts version bumps while still covering
* the prose contract the sub-page depends on.
*
* v1.4.25 W3f migrated the chart from "30-day average composition" to
* "per-night stacked bars with 7/14/30d toggle". The tests now pin:
* - 7-day default window + toggle button rendering
* - Per-night dataset slicing (perNight branch)
* - Empty-state when neither perNight nor aggregate has data
* - Graceful degradation when perNight is absent (legacy aggregate
* path stays render-safe)
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SleepStageStackedBar>", () => {
it("renders the composition title with the nights count", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 23,
totalMinutes: 460,
stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30, IN_BED: 30 },
perNight: [
{
dayKey: "2026-05-08",
stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30, IN_BED: 30 },
},
],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain("Stage composition");
expect(html).toContain("Last 23 nights");
});
it("exposes an accessible label that announces the nights covered", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 14,
totalMinutes: 100,
stages: { CORE: 100 },
perNight: [{ dayKey: "2026-05-08", stages: { CORE: 100 } }],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toMatch(/aria-label="Sleep stage composition over 14 nights"/);
});
it("renders German labels under the de locale", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 7,
totalMinutes: 200,
stages: { DEEP: 200 },
perNight: [{ dayKey: "2026-05-08", stages: { DEEP: 200 } }],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />, "de");
expect(html).toContain("Phasen-Verteilung");
expect(html).toContain("Letzte 7 Nächte");
});
it("does not crash when the breakdown carries unknown stage keys", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 3,
totalMinutes: 200,
stages: { DEEP: 100, UNKNOWN_STAGE: 100 },
perNight: [
{ dayKey: "2026-05-08", stages: { DEEP: 100, UNKNOWN_STAGE: 100 } },
],
};
expect(() =>
render(<SleepStageStackedBar breakdown={breakdown} />),
).not.toThrow();
});
/**
* v1.4.25 W3f — per-night surface + window toggle.
*/
describe("per-night + window toggle (v1.4.25 W3f)", () => {
it("renders three window-toggle buttons (7 / 14 / 30 days) with 7d active by default", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 7,
totalMinutes: 3000,
stages: { DEEP: 600, REM: 700, CORE: 1500, AWAKE: 200 },
perNight: Array.from({ length: 7 }, (_, i) => ({
dayKey: `2026-05-0${i + 1}`,
stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30 },
})),
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain('data-slot="sleep-stage-window-toggle"');
expect(html).toContain('data-slot="sleep-stage-window-7"');
expect(html).toContain('data-slot="sleep-stage-window-14"');
expect(html).toContain('data-slot="sleep-stage-window-30"');
// The 7d button is default-active.
expect(html).toMatch(
/data-slot="sleep-stage-window-7"[^>]*aria-pressed="true"/,
);
expect(html).toMatch(
/data-slot="sleep-stage-window-14"[^>]*aria-pressed="false"/,
);
});
it("renders an empty-state caption when the user has no per-night data", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 0,
totalMinutes: 0,
stages: {},
perNight: [],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain('data-slot="sleep-stage-empty"');
// No img-role wrapper when the chart is in empty state.
expect(html).not.toMatch(/role="img"\s+aria-label="Sleep stage/);
});
it("degrades gracefully when perNight is absent (legacy payload)", () => {
// Pre-W3f payloads don't carry perNight. The chart should still
// render the aggregate as a single bar so the user isn't left
// staring at an empty card during the rollout.
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 21,
totalMinutes: 9000,
stages: { DEEP: 1500, REM: 2000, CORE: 5000, AWAKE: 500 },
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
// No empty-state surface because the aggregate still has data.
expect(html).not.toContain('data-slot="sleep-stage-empty"');
// The chart card still mounts.
expect(html).toContain('data-slot="sleep-stage-stacked-bar"');
});
it("handles missing-some-nights gracefully (sparse perNight series)", () => {
// The 7-day window should render even if perNight has gaps —
// each entry stands alone.
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 4,
totalMinutes: 2000,
stages: { DEEP: 400, REM: 500, CORE: 1000, AWAKE: 100 },
perNight: [
{ dayKey: "2026-05-02", stages: { DEEP: 100, REM: 150, CORE: 300 } },
{ dayKey: "2026-05-04", stages: { DEEP: 100, REM: 150, CORE: 200 } },
{ dayKey: "2026-05-06", stages: { DEEP: 100, REM: 100, CORE: 200 } },
{ dayKey: "2026-05-08", stages: { DEEP: 100, REM: 100, CORE: 300 } },
],
};
expect(() =>
render(<SleepStageStackedBar breakdown={breakdown} />),
).not.toThrow();
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
// Chart still mounts + window toggle present.
expect(html).toContain('data-slot="sleep-stage-stacked-bar"');
expect(html).toContain('data-slot="sleep-stage-window-toggle"');
});
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SubPageShell } from "../sub-page-shell";
/**
* v1.4.25 W4 — `<SubPageShell>` unit tests.
*
* The shell renders a focusable `<h1>` (for screen-reader landing on
* tab navigation) plus an optional description and badge. SSR-rendered
* markup is enough to assert the heading hierarchy + tabIndex contract.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<SubPageShell>", () => {
it("renders the title as a focusable h1", () => {
const html = render(
<SubPageShell title="Blood pressure">
<p>child</p>
</SubPageShell>,
);
expect(html).toMatch(/<h1[^>]*id="insights-subpage-title"/);
expect(html).toMatch(/tabindex="-1"/);
expect(html).toContain("Blood pressure");
expect(html).toContain("<p>child</p>");
});
it("renders the description paragraph when supplied", () => {
const html = render(
<SubPageShell title="Weight" description="One paragraph">
<span />
</SubPageShell>,
);
expect(html).toContain("One paragraph");
});
it("omits the description block when undefined", () => {
const html = render(
<SubPageShell title="Pulse">
<span />
</SubPageShell>,
);
// The shell uses a `<p>` for the description; no `<p>` appears when
// the description prop is absent.
expect(html).not.toMatch(/<p[^>]*class="text-muted-foreground/);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
@@ -10,6 +11,12 @@ import { I18nProvider } from "@/lib/i18n/context";
* snapshots show the loading skeleton — that's still enough to verify
* the row's layout chrome (3-up grid wrapper + per-metric annotation
* slots).
*
* v1.4.25 W7 added a `useAuth()` call inside `<TrendsRow>` to plumb
* `userTimezone` through to the dynamically-imported chart components,
* so the test wraps every render in a TanStack-Query provider — the
* stub charts never trigger an actual fetch but `useAuth` requires a
* client to mount.
*/
vi.mock("next/dynamic", () => ({
@@ -25,8 +32,13 @@ vi.mock("next/dynamic", () => ({
import { TrendsRow } from "../trends-row";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.25 W16a — `<Vo2MaxChartRow>` SSR coverage.
*
* Acceptance:
* 1. Stat strip renders four data-slot cells: latest, min, max, avg30.
* 2. When `summary.count === 0`, the chart is replaced by the empty-state
* hint; the stat strip remains so a brand-new account still gets a
* consistent shell.
* 3. With a populated summary, the dynamic chart stub mounts (we mock
* `next/dynamic` so the Recharts dependency doesn't pull in DOM
* APIs unavailable in jsdom-less SSR).
* 4. Compare-delta caption only renders when `compareBaseline` is set
* AND the summary carries `avg30LastMonth` / `avg30LastYear` data.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="health-chart-stub">chart</div>;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
import { Vo2MaxChartRow } from "../vo2-max-chart-row";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const populatedSummary: DataSummary = {
count: 12,
latest: 42.3,
min: 38.1,
max: 45.7,
mean: 41.5,
avg7: 42.0,
avg30: 41.8,
slope7: null,
slope30: { direction: "up", slope: 0.05, confidence: 0.42 },
slope90: null,
anomalyCount: 0,
avg30LastMonth: 40.4,
avg30LastYear: null,
};
const emptySummary: DataSummary = {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
describe("<Vo2MaxChartRow>", () => {
it("renders the four stat cells regardless of data state", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
expect(html).toContain('data-stat="latest"');
expect(html).toContain('data-stat="min"');
expect(html).toContain('data-stat="max"');
expect(html).toContain('data-stat="avg30"');
});
it("mounts the chart stub when the summary carries data", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
expect(html).toContain('data-slot="health-chart-stub"');
// The empty-state hint must NOT appear when the chart is mounted.
expect(html).not.toContain('data-slot="vo2-chart-row-empty"');
});
it("falls back to the empty-state hint when count is zero", () => {
const html = render(<Vo2MaxChartRow summary={emptySummary} />);
expect(html).toContain('data-slot="vo2-chart-row-empty"');
expect(html).not.toContain('data-slot="health-chart-stub"');
// Stat strip stays mounted with em-dashes for the empty values.
expect(html).toContain('data-stat="latest"');
});
it("renders no compare-delta caption when comparison is off", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
expect(html).not.toContain('data-slot="vo2-compare-delta"');
});
it("renders a compare-delta caption when comparison is on and data exists", () => {
const html = render(
<Vo2MaxChartRow
summary={populatedSummary}
compareBaseline="lastMonth"
/>,
);
expect(html).toContain('data-slot="vo2-compare-delta"');
expect(html).toContain('data-compare-baseline="lastMonth"');
});
it("suppresses the compare-delta caption when the prior-period bucket is empty", () => {
const html = render(
<Vo2MaxChartRow
summary={populatedSummary}
compareBaseline="lastYear"
/>,
);
// avg30LastYear is null in the fixture, so the delta caption stays
// suppressed even though comparison is enabled.
expect(html).not.toContain('data-slot="vo2-compare-delta"');
});
it("renders the German labels under the de locale", () => {
const html = render(<Vo2MaxChartRow summary={emptySummary} />, "de");
expect(html).toContain("Aktuell");
expect(html).toMatch(/Apple Health/i);
});
});
@@ -11,16 +11,17 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<CoachInput>", () => {
it("mounts the textarea, mic, and send button slots", () => {
it("mounts the textarea + send button slots (mic dropped in W5)", () => {
// v1.4.22 B4 — the disclaimer moved to the sources rail; the
// composer no longer renders its own paragraph below the input.
// v1.4.25 W5 — the non-functional mic icon was removed.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain('data-slot="coach-input"');
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
expect(html).not.toContain("Coach replies are generated");
});
@@ -41,20 +42,16 @@ describe("<CoachInput>", () => {
expect(html).toContain("Frag mich etwas zu deinen Daten");
});
it("disables the mic button with the v1.5 tooltip text", () => {
it("no longer renders a mic button (W5 removed the placeholder)", () => {
// v1.4.25 W5 — the mic icon used to be rendered + disabled with a
// "voice arrives with iOS" tooltip. Marc flagged it as a click-
// trap: nothing happened on tap. The composer now drops the icon
// entirely; voice input remains a v1.5 iOS feature.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
// The button carries both the disabled attribute and the
// localised v1.5 voice tooltip on aria-label. Attribute order is
// not guaranteed across React versions, so we check for both
// independently inside the same tag.
const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toContain("disabled");
expect(micTag?.[0]).toContain(
'aria-label="Voice input arrives with the iOS app in v1.5."',
);
expect(html).not.toMatch(/data-slot="coach-input-mic"/);
expect(html).not.toContain("Voice input arrives with the iOS app");
});
it("disables the send button when the value is empty", () => {
@@ -124,10 +121,57 @@ describe("<CoachInput>", () => {
expect(handler).toHaveBeenCalledWith("typed");
});
it("renders the textarea with rows=2 to match the artboard", () => {
it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => {
// v1.4.25 W5 — Claude-web-style auto-grow. SSR baseline is a
// single-line textarea; the client-side `useEffect` measures
// `scrollHeight` and grows the height up to ~6 lines. The static
// markup must show `rows="1"` so the initial paint matches the
// disclaimer text height on the left side of the row.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="2"/);
expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="1"/);
// The textarea caps growth via the `max-h-[9.5rem]` class so the
// composer never pushes the rest of the drawer off-screen.
expect(html).toMatch(
/data-slot="coach-input-textarea"[^>]*class="[^"]*max-h-\[9\.5rem\]/,
);
});
});
// v1.4.25 W5 — `computeAutoGrowHeight` is the pure helper backing the
// textarea's auto-grow effect. Pin its math so the textarea never
// collapses below a single line or grows past the 6-line ceiling.
import { computeAutoGrowHeight } from "../coach-input";
describe("computeAutoGrowHeight", () => {
it("clamps to the single-line minimum when scrollHeight is empty", () => {
const out = computeAutoGrowHeight({
lineHeight: 20,
scrollHeight: 0,
maxLines: 6,
paddingY: 10,
});
expect(out).toBe(20 + 10);
});
it("returns the natural scrollHeight when below the cap", () => {
const out = computeAutoGrowHeight({
lineHeight: 20,
scrollHeight: 60,
maxLines: 6,
paddingY: 10,
});
expect(out).toBe(60);
});
it("caps at maxLines × lineHeight + paddingY", () => {
const out = computeAutoGrowHeight({
lineHeight: 20,
scrollHeight: 999,
maxLines: 6,
paddingY: 10,
});
expect(out).toBe(20 * 6 + 10);
});
});
@@ -104,4 +104,14 @@ describe("<CoachSettingsSheet>", () => {
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved.");
});
// v1.4.25 W5 — defaultWindow picker added.
it("renders the default-window picker once persisted prefs land", () => {
queryState.data = DEFAULT_COACH_PREFS;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).toContain('data-slot="coach-prefs-default-window"');
expect(html).toContain("Default analysis window");
});
});
@@ -304,10 +304,13 @@ describe("<MessageThread>", () => {
expect(html).toContain('data-slot="coach-evidence-list"');
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
expect(rows).toBe(2);
expect(html).toContain("avg7 systolic");
// v1.4.25 W5 — per-row source labels were dropped. The values +
// units + window framing stay; the redundant `kv.label` prefix
// (e.g. "avg7 systolic:") does not appear.
expect(html).not.toContain("avg7 systolic");
expect(html).not.toContain("avg30 systolic");
expect(html).toContain("138 mmHg");
expect(html).toContain("(last7days)");
expect(html).toContain("avg30 systolic");
expect(html).toContain("134 mmHg");
// Disclosure is collapsed by default (no `open` attribute).
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
@@ -355,11 +358,11 @@ describe("<MessageThread>", () => {
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain("30-day adherence");
// No stray empty unit/window markup.
expect(html).not.toMatch(
/30-day adherence:\s*<\/span>\s*<strong[^>]*>\s*</,
);
// v1.4.25 W5 — `kv.label` no longer renders; only the value
// bubbles up. The row still mounts so the value is visible.
expect(html).not.toContain("30-day adherence");
expect(html).toContain('data-slot="coach-evidence-row"');
expect(html).toMatch(/<strong[^>]*>\s*96\s*<\/strong>/);
});
it("hides the disclosure entirely when keyValues is empty or absent", () => {
@@ -400,4 +403,138 @@ describe("<MessageThread>", () => {
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
});
// v1.4.25 W5 — optimistic user bubble appears before the "Thinking…"
// placeholder so the visible order matches the user's mental model.
it("renders the optimistic user bubble before the streaming Thinking placeholder", () => {
const html = render(
<MessageThread
conversation={null}
optimisticUser={{
localId: "local-1",
content: "Wie ist mein Blutdruck letzte Woche?",
conversationId: null,
}}
streaming={{
content: "",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
expect(html).toContain("Wie ist mein Blutdruck letzte Woche?");
expect(html).toContain("Thinking");
// The user bubble appears before the assistant bubble in the DOM.
const userIdx = html.indexOf('data-slot="coach-bubble-user"');
const assistantIdx = html.indexOf('data-slot="coach-bubble-assistant"');
expect(userIdx).toBeGreaterThan(-1);
expect(assistantIdx).toBeGreaterThan(-1);
expect(userIdx).toBeLessThan(assistantIdx);
});
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
it("surfaces the daily-limit copy for coach.budget.exceeded", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
}}
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
expect(html).not.toContain("could not reach an AI provider");
});
it("surfaces the provider rate-limit copy for coach.provider.rate_limited", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
}}
/>,
);
expect(html).toContain(
"Provider temporarily rate-limited; retry in ~5 min.",
);
});
it("suppresses the optimistic user bubble once its persisted twin lands", () => {
// baseConversation already carries a user message "Why was BP
// higher on Monday?". Passing the same content as the optimistic
// bubble simulates the post-`done` invalidate-refetch landing the
// persisted user message — the optimistic copy must drop so the
// user never sees their bubble twice.
const html = render(
<MessageThread
conversation={baseConversation}
optimisticUser={{
localId: "local-2",
content: "Why was BP higher on Monday?",
conversationId: "conv-1",
}}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: null,
}}
/>,
);
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
.length;
expect(userBubbles).toBe(1);
});
});
// v1.4.25 W5 — pin the error-code → i18n-key resolver so future code
// changes can't silently demote the daily-limit / rate-limit copy
// back to the generic provider-unavailable fallback.
import { errorCodeToI18nKey } from "../message-thread";
describe("errorCodeToI18nKey", () => {
it("maps daily-budget exceedance to the dedicated key", () => {
expect(errorCodeToI18nKey("coach.budget.exceeded")).toBe(
"insights.coach.dailyLimitBody",
);
});
it("maps provider rate-limit to the dedicated key", () => {
expect(errorCodeToI18nKey("coach.provider.rate_limited")).toBe(
"insights.coach.providerRateLimitBody",
);
});
it("maps every other provider failure to the generic copy", () => {
for (const code of [
"coach.provider.unavailable",
"coach.provider.empty",
"coach.provider.none",
"coach.network",
"coach.stream",
]) {
expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider");
}
});
it("forward-compats unknown codes with the namespaced key", () => {
expect(errorCodeToI18nKey("errorProvider")).toBe(
"insights.coach.errorProvider",
);
expect(errorCodeToI18nKey("brand-new-code")).toBe(
"insights.coach.brand-new-code",
);
});
});
@@ -2,11 +2,19 @@
import { useCallback, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Plus, Settings, Sparkles } from "lucide-react";
import { Plus, Settings, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
@@ -14,6 +22,7 @@ import {
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
@@ -153,6 +162,13 @@ export function CoachDrawer({
// selector). Resets to the all-source last30days default each time
// the drawer mounts; no conversation-level persistence in this
// hotfix per the v1.4.20.1 plan.
//
// v1.4.25 W5 — the scope.window field is now a two-layer override:
// • the user's saved `coachPrefs.defaultWindow` is the base
// • the header pill drops a per-conversation override into
// `windowOverride`; null = "use the saved default"
// The override resets to null on drawer close so the next session
// starts on the user's saved preference.
const [scope, setScope] = useState<{
sources: CoachScopeSource[];
window: CoachScopeWindow;
@@ -160,15 +176,32 @@ export function CoachDrawer({
sources: [...DEFAULT_COACH_SCOPE.sources],
window: DEFAULT_COACH_SCOPE.window,
}));
const [windowOverride, setWindowOverride] = useState<CoachScopeWindow | null>(
null,
);
// v1.4.25 W5 — load the user's saved Coach prefs so the default
// window picks up the cog's saved selection. The hook gates the
// fetch on `enabled` so the network call only fires while the
// drawer is open. Falls through to the legacy "last30days" default
// when the row is missing or the request is in flight.
const { data: coachPrefs } = useCoachPrefs({ enabled: open });
const savedDefaultWindow: CoachScopeWindow =
coachPrefs?.defaultWindow ?? DEFAULT_COACH_SCOPE.window;
const effectiveWindow: CoachScopeWindow =
windowOverride ?? savedDefaultWindow;
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
// Reset thread + composer on close so the next open starts on
// the rail's empty hint instead of re-rendering the previous
// conversation by accident.
// conversation by accident. v1.4.25 W5 — also drop the
// per-conversation window override so the next session starts
// on the user's saved `coachPrefs.defaultWindow` again.
setCurrentConversationId(null);
setInputValue("");
setWindowOverride(null);
}
onOpenChange(next);
},
@@ -189,15 +222,21 @@ export function CoachDrawer({
// Pass the scope only when the user has narrowed it from the
// defaults — keeps the wire payload minimal and lets the route
// tell "no opinion" apart from "intentionally narrow".
const isDefault =
scope.window === DEFAULT_COACH_SCOPE.window &&
//
// v1.4.25 W5 — `scope.window` now reflects the per-conversation
// override (header pill / rail picker) layered on top of the saved
// `coachPrefs.defaultWindow`. Send the override only when the user
// explicitly diverged from the saved default; otherwise the route
// re-applies the saved preference itself.
const allSourcesSelected =
scope.sources.length === DEFAULT_COACH_SCOPE.sources.length &&
DEFAULT_COACH_SCOPE.sources.every((s) => scope.sources.includes(s));
const isDefault = allSourcesSelected && windowOverride === null;
const scopePayload: CoachScope | undefined = isDefault
? undefined
: {
sources: scope.sources,
window: scope.window,
window: effectiveWindow,
};
await send.send({
conversationId: currentConversationId ?? undefined,
@@ -218,7 +257,13 @@ export function CoachDrawer({
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
showCloseButton
// v1.4.25 W5 — render our own Close button inside the header so
// X / cog / new-chat sit on the same baseline with identical
// size, color, and hit target. The Sheet's default close-X is
// absolutely positioned with `opacity-70` + tiny `rounded-xs`,
// which Marc flagged as misaligned with the rest of the header
// cluster.
showCloseButton={false}
data-slot="coach-drawer"
className={cn(
// Drawer keeps the dashboard context behind it. On laptops
@@ -234,13 +279,16 @@ export function CoachDrawer({
)}
>
{/* Header (full width). Avatar + title + new-chat button +
settings cog (v1.4.23 H4 — real per-user prompt-tuning
surface, replacing the v1.4.22 B5 dead-button cleanup).
pr-12 keeps the buttons clear of the Sheet's close-X on
narrower viewports. */}
settings cog + close X. v1.4.25 W5 — the three header
actions (new chat, settings, close) all use the same
`ghost / size-icon / size-9` shape so they share a single
visual cluster instead of feeling like three different
controls. The close X used to be the Sheet's absolutely
positioned default; bringing it inline normalises the row
and frees the `pr-*` reservation. */}
<SheetHeader
data-slot="coach-drawer-header"
className="border-border/70 flex-row items-center gap-3 border-b p-3 pr-12 sm:p-4 sm:pr-14"
className="border-border/70 flex-row items-center gap-2 border-b p-3 sm:gap-3 sm:p-4"
>
<div
aria-hidden="true"
@@ -256,18 +304,66 @@ export function CoachDrawer({
{t("insights.coach.tagline")}
</SheetDescription>
</div>
{/* v1.4.25 W5 — per-conversation window override. The pill
defaults to the user's saved `coachPrefs.defaultWindow`
and resets to it on drawer close. Changing the pill flips
`windowOverride`; the rail's window picker mirrors the
same source-of-truth so the user can drive the override
from either surface. */}
<Select
value={effectiveWindow}
onValueChange={(value) => {
const next = value as CoachScopeWindow;
setWindowOverride(next === savedDefaultWindow ? null : next);
}}
>
<SelectTrigger
data-slot="coach-drawer-window-pill"
aria-label={t("insights.coach.windowLabel")}
size="sm"
className={cn(
"border-border/60 bg-muted/40 text-foreground h-7 shrink-0 gap-1 rounded-full px-2.5 text-[11px]",
"hover:bg-muted/60 focus-visible:ring-ring/40 focus-visible:ring-2",
windowOverride !== null &&
"border-dracula-purple/40 bg-dracula-purple/10 text-dracula-purple",
)}
>
<SelectValue
placeholder={t(`insights.coach.window.${effectiveWindow}`)}
/>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="last7days" className="text-xs">
{t("insights.coach.window.last7days")}
</SelectItem>
<SelectItem value="last30days" className="text-xs">
{t("insights.coach.window.last30days")}
</SelectItem>
<SelectItem value="last90days" className="text-xs">
{t("insights.coach.window.last90days")}
</SelectItem>
<SelectItem value="allTime" className="text-xs">
{t("insights.coach.window.allTime")}
</SelectItem>
</SelectContent>
</Select>
{/* v1.4.25 W5 — header action cluster. All three buttons
share the same `ghost / size-icon / size-9` shape so they
visually belong together. 36×36 px hit target meets the
WCAG 2.1 AA touch-target minimum on mobile; the icons
themselves stay 18 px to match the avatar's optical
weight. */}
<Button
type="button"
variant="ghost"
size="sm"
size="icon"
onClick={handleNewChat}
data-slot="coach-drawer-new-chat"
className="shrink-0 gap-1.5"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="text-muted-foreground hover:text-foreground size-9 shrink-0"
>
<Plus className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">
{t("insights.coach.newChat")}
</span>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
@@ -276,10 +372,24 @@ export function CoachDrawer({
onClick={() => setSettingsOpen(true)}
data-slot="coach-drawer-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
className="size-8 shrink-0"
title={t("insights.coach.settingsAriaLabel")}
className="text-muted-foreground hover:text-foreground size-9 shrink-0"
>
<Settings className="size-3.5" aria-hidden="true" />
<Settings className="size-4" aria-hidden="true" />
</Button>
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-drawer-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground size-9 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
</SheetHeader>
{/* Body — three columns on lg+, single column on smaller.
@@ -297,13 +407,27 @@ export function CoachDrawer({
}
sourcesRail={
sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
<SourcesRail
// v1.4.25 W5 — overlay the effective window on the rail
// so the picker mirrors the per-conversation override
// (or the saved default when no override is active).
// The rail's own onChange flips `windowOverride` so the
// rail picker is just another way to set the override.
scope={{ sources: scope.sources, window: effectiveWindow }}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)
}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
/>
}
composer={
@@ -373,7 +497,18 @@ export function CoachDrawer({
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
<SourcesRail
scope={{
sources: scope.sources,
window: effectiveWindow,
}}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)}
</div>
</SheetContent>
@@ -1,23 +1,23 @@
"use client";
import { type FormEvent, type KeyboardEvent, useCallback } from "react";
import { Loader2, Mic, Send } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
} from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.20 phase B2b — Coach composer.
*
* `<textarea>` wrapped in a Dracula-styled card with a send button +
* mic placeholder. Submit fires on:
* `<textarea>` wrapped in a Dracula-styled card with a send button.
* Submit fires on:
* - Enter (no Shift) → send
* - Cmd/Ctrl + Enter → send (parity with the artboard ⌘↵ chip)
* - Shift + Enter → newline (default browser behaviour)
@@ -26,13 +26,13 @@ import { useTranslations } from "@/lib/i18n/context";
* the drawer. `disabled` flips during a streamed reply so we never
* fire two requests in parallel.
*
* Mic is rendered but disabled with a tooltip ("Voice input arrives
* with the iOS app in v1.5") because the natural shipper of voice is
* the native client, not the PWA.
*
* v1.4.22 B4: the disclaimer ("Coach replies are generated …") moved
* out of the composer and into the sources rail footer, so the
* composer stays focused on the input affordance.
*
* v1.4.25 W5: dropped the non-functional mic icon. The voice-input
* affordance ships with the iOS client; surfacing a placeholder in the
* web composer drew clicks for an action that did nothing.
*/
export interface CoachInputProps {
value: string;
@@ -46,6 +46,41 @@ export interface CoachInputProps {
inputId?: string;
}
/**
* v1.4.25 W5 — Claude-web-style auto-grow.
*
* The composer textarea starts at 1 line (≈44 px including padding,
* matching the disclaimer text height on the sources rail) and grows
* with content up to 6 lines (≈144 px). Past 6 lines the textarea
* scrolls internally. Implemented as a plain `scrollHeight` measurement
* — no external auto-resize library, no `contenteditable` round-trip,
* no `field-sizing` (still Chrome-only in 2026-Q2).
*
* Pure helper so the math can be unit-tested without a DOM:
* - `lineHeight`: cached line-height of the textarea (px)
* - `scrollHeight`: textarea's natural `scrollHeight` after height
* was reset to "auto"
* - `maxLines`: hard cap (6); past this the textarea scrolls
* Returns the height in px to apply, clamped to [minHeight, maxHeight].
*/
export function computeAutoGrowHeight(args: {
lineHeight: number;
scrollHeight: number;
maxLines: number;
paddingY: number;
}): number {
const min = args.lineHeight + args.paddingY;
const max = args.lineHeight * args.maxLines + args.paddingY;
// Honour the existing scrollHeight (which already includes padding)
// but clamp it into the [min,max] band. The minimum guarantees the
// textarea never collapses below a single line when the value is
// empty; the maximum prevents the row from pushing the composer
// taller than 6 lines.
return Math.max(min, Math.min(args.scrollHeight, max));
}
const AUTO_GROW_MAX_LINES = 6;
export function CoachInput({
value,
onChange,
@@ -55,6 +90,35 @@ export function CoachInput({
inputId = "coach-composer-textarea",
}: CoachInputProps) {
const { t } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// v1.4.25 W5 — auto-grow side effect. Runs on every value change
// (the controlled `value` prop is the source of truth) plus on mount
// so the initial paint already shows the 1-line height even when the
// value arrives prefilled. We reset `style.height` to "auto" first so
// shrinking text (e.g. after the user hits send) also collapses the
// textarea — without the reset, `scrollHeight` only ever monotonically
// grows. Reads `getComputedStyle` for line-height + padding once per
// tick so a CSS-only theme change re-applies the right minimum.
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
// Reset so the browser re-measures the natural content height.
el.style.height = "auto";
const computed = window.getComputedStyle(el);
const rawLineHeight = parseFloat(computed.lineHeight);
const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 20;
const paddingY =
(parseFloat(computed.paddingTop) || 0) +
(parseFloat(computed.paddingBottom) || 0);
const height = computeAutoGrowHeight({
lineHeight,
scrollHeight: el.scrollHeight,
maxLines: AUTO_GROW_MAX_LINES,
paddingY,
});
el.style.height = `${height}px`;
}, [value]);
const canSubmit = !disabled && value.trim().length > 0;
@@ -91,39 +155,26 @@ export function CoachInput({
>
<textarea
id={inputId}
ref={textareaRef}
data-slot="coach-input-textarea"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("insights.coach.composerPlaceholder")}
disabled={disabled}
rows={2}
// v1.4.25 W5 — single-line initial state, Claude-web-style.
// `rows={1}` is the SSR-stable baseline; the `useEffect`
// above grows the textarea up to `AUTO_GROW_MAX_LINES`. Past
// the cap the textarea scrolls internally (overflow-auto via
// `max-h-[9.5rem]`, ≈6 lines at the current line-height).
rows={1}
className={cn(
"w-full resize-none bg-transparent text-sm leading-relaxed outline-none",
"max-h-[9.5rem] overflow-auto",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
<div className="mt-1.5 flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
aria-label={t("insights.coach.voiceComingSoon")}
data-slot="coach-input-mic"
className="size-8"
>
<Mic className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.voiceComingSoon")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span
data-slot="coach-input-hint"
className="text-muted-foreground text-[11px]"
@@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_PREFS,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
@@ -71,6 +72,16 @@ const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"steps",
];
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
// straight into `scope.window` when the client didn't supply one.
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
export function CoachSettingsSheet({
open,
onOpenChange,
@@ -237,6 +248,46 @@ export function CoachSettingsSheet({
</Select>
</div>
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
scope window the Coach reads when the client doesn't
supply a per-conversation override. The drawer header
carries a small pill that overrides the picked default
for the current chat only. */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Exclude metrics */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
@@ -13,12 +13,14 @@ import {
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import { SourceChips } from "./source-chips";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
CoachStreamingMessage,
} from "./use-coach";
import type { CoachMessageDTO } from "@/lib/ai/coach/types";
@@ -44,6 +46,15 @@ export interface MessageThreadProps {
conversation: CoachConversationDetailDTO | null;
/** Optional in-flight bubble from `useSendCoachMessage()`. */
streaming?: CoachStreamingMessage;
/**
* v1.4.25 W5 — optimistic user message surfaced by the send hook so
* the user sees their own bubble before the "Thinking…" placeholder.
* Cleared by the hook once the SSE `done` frame fires (the persisted
* twin lands via the invalidate-refetch). When the persisted twin is
* already in `conversation.messages` we suppress the optimistic copy
* so the user never sees the same bubble twice.
*/
optimisticUser?: CoachOptimisticUserMessage | null;
/** Empty-state copy when no conversation is loaded yet. */
emptyHint?: string;
}
@@ -52,9 +63,42 @@ function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= slack;
}
/**
* v1.4.25 W5 — map server-emitted error codes to specific Coach i18n
* keys. The chat route distinguishes the daily user-quota
* (`coach.budget.exceeded`, returned as a JSON 429) from the provider
* rate-limit (`coach.provider.rate_limited`, streamed as an SSE error
* frame). Both used to surface as the generic provider-unavailable
* copy; we now route each to its dedicated translation so the user
* understands whether the limit is on their side (reset at UTC
* midnight) or transient on the provider side (retry in ~5 min).
*
* Exported so the resolver can be pinned by unit tests without
* standing up the whole thread renderer.
*/
export function errorCodeToI18nKey(code: string): string {
switch (code) {
case "coach.budget.exceeded":
return "insights.coach.dailyLimitBody";
case "coach.provider.rate_limited":
return "insights.coach.providerRateLimitBody";
case "coach.provider.unavailable":
case "coach.provider.empty":
case "coach.provider.none":
case "coach.network":
case "coach.stream":
return "insights.coach.errorProvider";
default:
// Forward-compat: try `insights.coach.<code>` for codes that
// ship their own translation (e.g. legacy `errorProvider`).
return `insights.coach.${code}`;
}
}
export function MessageThread({
conversation,
streaming,
optimisticUser,
emptyHint,
}: MessageThreadProps) {
const { t } = useTranslations();
@@ -116,6 +160,21 @@ export function MessageThread({
!streamingPersisted &&
(!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode);
// v1.4.25 W5 — render the optimistic user bubble only when the
// persisted twin hasn't landed yet. We match on (role=user, content
// equality, no later persisted user message). The server is the
// source of truth — once the persisted user message lands (via the
// invalidate-refetch the SSE `done` frame triggers), the optimistic
// copy is dropped so the user never sees their bubble twice.
const optimisticActive = (() => {
if (!optimisticUser) return false;
// Suppress if the persisted history already contains the same
// user content as the last user message — the twin has landed.
const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (lastUser && lastUser.content === optimisticUser.content) return false;
return true;
})();
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
@@ -129,16 +188,18 @@ export function MessageThread({
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
// when the user was already at the bottom.
// when the user was already at the bottom. v1.4.25 W5 — the
// optimistic user bubble counts as a new message; scroll on its
// localId so the user sees their own bubble land at the bottom.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
if (wasPinnedRef.current) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}, [messages.length, streaming?.content]);
}, [messages.length, streaming?.content, optimisticUser?.localId]);
if (messages.length === 0 && !streamingActive) {
if (messages.length === 0 && !streamingActive && !optimisticActive) {
return (
<div
data-slot="coach-message-thread"
@@ -183,6 +244,18 @@ export function MessageThread({
/>
);
})}
{/* v1.4.25 W5 — optimistic user bubble surfaces between the
persisted history and the streaming assistant placeholder so
the visible order matches the user's mental model. The
send-hook drops it as soon as the SSE `done` frame fires +
the invalidate-refetch lands the persisted twin. */}
{optimisticActive && optimisticUser && (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
)}
{streamingActive && streaming && (
// role=log + aria-live=polite so screen-reader users hear the
// assistant prose announce as tokens land. aria-relevant=text
@@ -293,12 +366,18 @@ function ChatBubble({
);
}
const errorMessage = errorCode ? t(`insights.coach.${errorCode}`, {}) : null;
// When a translated message comes back unchanged (i.e. key missing) we
// fall back to a generic provider error string so the bubble doesn't
// surface raw `coach.http.503` text to the user.
// v1.4.25 W5 — map server-emitted error codes to specific Coach
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
// user understands daily-cap (resets at UTC midnight) vs. transient
// provider load (retry in ~5 min). Codes that have no dedicated
// translation fall back to the generic provider-unavailable copy.
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;
const errorMessage = errorKey ? t(errorKey, {}) : null;
// When a translated message comes back unchanged (i.e. key missing)
// we fall back to a generic provider error string so the bubble
// doesn't surface raw `coach.http.503` text to the user.
const safeError =
errorMessage && errorMessage !== `insights.coach.${errorCode}`
errorMessage && errorMessage !== errorKey
? errorMessage
: errorCode
? t("insights.coach.errorProvider")
@@ -330,7 +409,17 @@ function ChatBubble({
inProgress && "animate-pulse motion-reduce:animate-none",
)}
>
{content || (inProgress ? t("insights.coach.thinking") : "")}
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
the assistant prose before it lands in the bubble. The
raw `content` is the streamed Coach reply (or the
persisted twin after `done` fires); both paths are AI-
authored so they share the same leak surface as the
insight prose elsewhere. */}
{content
? stripChartTokens(content)
: inProgress
? t("insights.coach.thinking")
: ""}
</div>
{safeError && (
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
@@ -374,7 +463,12 @@ function ChatBubble({
data-slot="coach-evidence-row"
className="leading-relaxed"
>
<span className="text-muted-foreground">{kv.label}:</span>{" "}
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
@@ -202,6 +202,37 @@ export interface CoachStreamingMessage {
errorCode: string | null;
}
/**
* v1.4.25 W5 — optimistic user bubble surfaced by the send hook so the
* thread paints the user's message immediately, before the assistant's
* "Thinking…" placeholder. The server persists the user message before
* the stream starts (see `appendMessage` in
* `src/app/api/insights/chat/route.ts`), but the persisted twin only
* lands client-side after the SSE `done` frame triggers a
* `queryClient.invalidateQueries`. The earlier render order was
* "Thinking…" → user message → assistant reply, which Marc flagged in
* the W5 polish brief; surfacing this optimistic bubble flips the
* order to user → "Thinking…" → assistant reply.
*
* The bubble holds the message text + the (parent-supplied)
* conversationId so the thread renderer can match it against the
* persisted history and suppress the twin during the same 150ms grace
* window that already guards the assistant streaming bubble (see
* `message-thread.tsx`).
*/
export interface CoachOptimisticUserMessage {
/** Local-only id so React keys stay stable across renders. */
localId: string;
/** The user's message text — exactly what was sent to the server. */
content: string;
/**
* Conversation id at the time the send fired. Null when the user is
* creating a brand-new conversation; the server assigns the id and
* emits it on the `done` frame.
*/
conversationId: string | null;
}
const EMPTY_STREAMING: CoachStreamingMessage = {
content: "",
metricSource: null,
@@ -248,6 +279,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
const queryClient = useQueryClient();
const [streaming, setStreaming] =
useState<CoachStreamingMessage>(EMPTY_STREAMING);
// v1.4.25 W5 — optimistic user bubble. Mounts the user's message in
// the thread immediately so the chronological render order matches
// the user's mental model (send → my message → coach "Thinking…" →
// reply). Cleared once the persisted twin lands via the invalidate-
// refetch the SSE `done` frame triggers.
const [optimisticUser, setOptimisticUser] =
useState<CoachOptimisticUserMessage | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Stash `opts` in a ref so callers can pass an inline-literal object
@@ -260,6 +298,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
const reset = useCallback(() => {
setStreaming(EMPTY_STREAMING);
setOptimisticUser(null);
}, []);
const send = useCallback(
@@ -269,6 +308,17 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
const controller = new AbortController();
abortRef.current = controller;
// v1.4.25 W5 — surface the user's message in the thread BEFORE
// the "Thinking…" placeholder paints. Without this the send hook
// only exposed the streaming assistant bubble, so the first
// thing the user saw was the placeholder followed by their own
// message landing on the next refetch — Marc flagged the
// order as confusing on the suggested-prompt chips.
setOptimisticUser({
localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
content: params.message,
conversationId: params.conversationId ?? null,
});
setStreaming({
content: "",
metricSource: null,
@@ -327,13 +377,33 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
.toLowerCase()
.startsWith("text/event-stream");
if (!response.ok && !isEventStream) {
// v1.4.25 W5 — for JSON errors the apiHandler envelope carries
// a structured `error` code (e.g. `coach.budget.exceeded`).
// Surface the structured code directly so the drawer can show
// the right copy + toast variant; fall back to the generic
// `coach.http.<status>` only when parsing the envelope fails.
let structured: string | null = null;
try {
const envelope = (await response.clone().json()) as {
error?: unknown;
};
if (typeof envelope?.error === "string") {
structured = envelope.error;
}
} catch {
// body was not JSON; fall through to the http-status fallback
}
setStreaming({
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
errorCode: structured ?? `coach.http.${response.status}`,
});
// Drop the optimistic user bubble on the error path too so
// the next refetch — which still has the persisted user
// message — doesn't render the same bubble twice.
setOptimisticUser(null);
return;
}
@@ -413,6 +483,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
optsRef.current.onDone?.(resolvedConversationId);
}
// v1.4.25 W5 — drop the optimistic user bubble; the persisted
// twin is on its way via the invalidate-refetch above. Drop on
// error too so the user message isn't doubled when the next
// refetch sees the server-persisted row.
setOptimisticUser(null);
},
[queryClient],
);
@@ -427,6 +502,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
send,
cancel,
reset,
optimisticUser,
};
}
+11 -3
View File
@@ -23,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
@@ -85,6 +86,11 @@ const METRIC_ICON: Record<
distance: Route,
vo2_max: Zap,
body_temp: Thermometer,
// ── v1.4.25 W4d GLP-1 additive ──
// Plateau finding inherits the Pill icon since it's an
// adherence-context observation about the user's GLP-1 therapy.
// Non-GLP-1 accounts never see this finding type.
glp1_plateau: Pill,
};
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
@@ -143,11 +149,13 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-3">
<p className="text-sm leading-snug font-medium">{finding.headline}</p>
<p className="text-sm leading-snug font-medium">
{stripChartTokens(finding.headline)}
</p>
<DeltaBadge delta={finding.delta} tone={finding.tone} />
</div>
<p className="text-muted-foreground text-xs leading-snug">
{finding.detail}
{stripChartTokens(finding.detail)}
</p>
</div>
</div>
@@ -228,7 +236,7 @@ export function DailyBriefing({
data-slot="daily-briefing-paragraph"
className="text-foreground text-sm leading-relaxed"
>
{briefing.paragraph}
{stripChartTokens(briefing.paragraph)}
</p>
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
+329 -15
View File
@@ -1,6 +1,7 @@
"use client";
import { ArrowDown, ArrowUp, Minus, Sparkles } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ChevronDown, Minus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -18,6 +19,12 @@ import { cn } from "@/lib/utils";
* ("Why is my health score X out of 100?") so the user can drill
* into the explanation without retyping the question
*
* v1.4.25 W8e — collapsible "Driven by" provenance accordion appended
* below the component sub-bars. Each row shows the value bar, the
* effective weight share, and a source pill so the user can see which
* ingest path produced each pillar. A "mixed source" banner fires when
* at least one component blends entries from more than one source.
*
* Pure presentational — the parent owns the analytics query + the
* drawer state. The same `onAskCoach` handler is shared with the hero
* strip's main "Ask the coach" button (different prefill string).
@@ -25,19 +32,80 @@ import { cn } from "@/lib/utils";
export type HealthScoreBand = "green" | "yellow" | "red";
/**
* v1.4.25 W8e — per-component source attribution. Mirrors the analytics
* `HealthScoreComponentSource` type (intentional duplication so the
* client doesn't pull a heavy analytics import for one string union).
*/
export type HealthScoreComponentSource =
| "manual"
| "withings"
| "appleHealth"
| "mixed"
| "none";
export interface HealthScoreCardComponent {
value: number | null;
weight: number;
/**
* v1.4.25 W8e — optional source label. When omitted the row falls
* back to `manual` for present values / `none` for null values.
*/
source?: HealthScoreComponentSource;
/**
* v1.4.25 W8e — optional ISO timestamp of the freshest contributing
* measurement. The provenance accordion surfaces it under "as of
* {date}" on each row.
*/
asOf?: string;
}
export interface HealthScoreCardProps {
score: number;
band: HealthScoreBand;
components: {
bp: { value: number | null; weight: number };
weight: { value: number | null; weight: number };
mood: { value: number | null; weight: number };
compliance: { value: number | null; weight: number };
bp: HealthScoreCardComponent;
weight: HealthScoreCardComponent;
mood: HealthScoreCardComponent;
compliance: HealthScoreCardComponent;
};
delta: number | null;
onAskCoach?: (prefill: string) => void;
/**
* v1.4.25 W8e — opens the provenance accordion on first render.
* The SSR tests use it to exercise the expanded markup without a
* `useEffect` round-trip; in production the user-driven toggle
* starts collapsed.
*/
initiallyExpanded?: boolean;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
const SOURCE_LABEL_KEY: Record<HealthScoreComponentSource, string> = {
manual: "insights.healthScore.provenance.sources.manual",
withings: "insights.healthScore.provenance.sources.withings",
appleHealth: "insights.healthScore.provenance.sources.appleHealth",
mixed: "insights.healthScore.provenance.sources.mixed",
none: "insights.healthScore.provenance.sources.none",
};
/**
* Per-source pill colour vocabulary — mirrors the Coach provenance
* `<SourceChips>` accent for `withings` (dracula-cyan) so the user
* reads "same provenance grammar across surfaces". Manual rides on
* dracula-purple, Apple Health on dracula-pink, mixed on dracula-yellow.
* `none` is a muted slate so the empty-state row reads as dimmed
* without losing the pill affordance entirely.
*/
const SOURCE_PILL_CLASS: Record<HealthScoreComponentSource, string> = {
manual: "border-dracula-purple/30 text-dracula-purple/90",
withings: "border-dracula-cyan/30 text-dracula-cyan/90",
appleHealth: "border-dracula-pink/30 text-dracula-pink/90",
mixed: "border-dracula-yellow/30 text-dracula-yellow/90",
none: "border-muted-foreground/30 text-muted-foreground",
};
const BAND_NUMBER_CLASS: Record<HealthScoreBand, string> = {
green: "text-dracula-green",
yellow: "text-dracula-orange",
@@ -66,22 +134,93 @@ const COMPONENT_LABEL_KEY: Record<
compliance: "insights.healthScore.componentCompliance",
};
// v1.4.25 W8e — provenance row order. Module-scope so the array is
// allocated once per process rather than on every card render; the
// downstream `.map()` is non-mutating, so a fresh copy isn't needed.
const COMPONENT_ORDER: readonly ComponentKey[] = [
"bp",
"weight",
"mood",
"compliance",
];
export function HealthScoreCard({
score,
band,
components,
delta,
onAskCoach,
initiallyExpanded = false,
}: HealthScoreCardProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
// (`title`) so the row layout stays one-line. Dates format via the
// user's locale to honour the EU comma / dot convention; on bad input
// we fall through to the raw ISO so the user still sees something.
// The formatter is memoised per locale so the row map below doesn't
// build a fresh `Intl.DateTimeFormat` instance per row.
const asOfFormatter = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "2-digit",
}),
[locale],
);
const formatAsOf = (asOf: string | null): string | null => {
if (!asOf) return null;
const parsed = new Date(asOf);
if (Number.isNaN(parsed.getTime())) return null;
return asOfFormatter.format(parsed);
};
const [expanded, setExpanded] = useState(initiallyExpanded);
// `useId` keeps the aria-controls/section-id pair unique even when
// the card is mounted twice on the same page (lg+ hero strip vs
// smaller stacked previews in tests).
const panelId = useId();
const componentEntries = (
Object.keys(components) as Array<keyof HealthScoreCardProps["components"]>
Object.keys(components) as Array<ComponentKey>
).map((key) => ({
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: components[key].value,
}));
// v1.4.25 W8e — sort the provenance rows by effective weight
// descending so the biggest contributor sits first. Components with
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
const provenanceRows = COMPONENT_ORDER
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
c.source ?? (c.value === null ? "none" : "manual");
return {
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: c.value,
weight: c.weight,
source: inferredSource,
asOf: c.asOf ?? null,
effective: c.weight * (c.value !== null ? 1 : 0),
};
})
.sort(
(a, b) =>
b.effective - a.effective || a.key.localeCompare(b.key),
);
const presentCount = provenanceRows.filter((r) => r.value !== null).length;
const totalCount = provenanceRows.length;
const hasMixed = provenanceRows.some((r) => r.source === "mixed");
// v1.4.25 W8e — "provisional" when less than half of the configured
// inputs have data. Subtle badge above the headline number; copy is
// localised via `provenance.provisional`.
const isProvisional =
totalCount > 0 && presentCount > 0 && presentCount < totalCount / 2;
return (
<div
data-slot="health-score-card"
@@ -89,17 +228,41 @@ export function HealthScoreCard({
className={cn(
"bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
BAND_BORDER_CLASS[band],
"w-full lg:w-[220px] lg:shrink-0",
// v1.4.25 W3 — Marc reported the German "Einnahmetreue" label
// overlapping the band-coloured component-value pill at the
// right of each row. The original `w-[220px]` left only ~64px
// for the label column after the bar + value chip ate the
// rest, which fits "Mood" / "BP" but truncates the longer
// German strings. Bumped to `w-[260px]` so the card sits
// ~18 % wider on `lg+` without disturbing the title block's
// visual centre of gravity; the label column is widened in
// step with this so the breathing room actually lands on the
// text.
"w-full lg:w-[260px] lg:shrink-0",
)}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p
data-slot="health-score-card-label"
className="text-muted-foreground text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{t("insights.healthScore.label")}
</p>
<div className="flex min-w-0 items-center gap-2">
<p
data-slot="health-score-card-label"
className="text-muted-foreground text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{t("insights.healthScore.label")}
</p>
{isProvisional && (
<span
data-slot="health-score-card-provisional-badge"
className="border-muted-foreground/30 text-muted-foreground inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-medium tracking-wide uppercase"
title={t("insights.healthScore.provenance.provisional", {
count: presentCount,
total: totalCount,
})}
>
{t("insights.healthScore.provenance.provisionalBadge")}
</span>
)}
</div>
{delta !== null && delta > 0 && (
<span
data-slot="health-score-card-delta-chip"
@@ -189,7 +352,11 @@ export function HealthScoreCard({
data-component={key}
className="flex items-center gap-2 text-[11px]"
>
<span className="text-muted-foreground w-16 shrink-0">
{/* v1.4.25 W3 — widened label column from w-16 (64px) to
w-24 (96px) so the longest German label
("Einnahmetreue" — 13 chars at 11px) sits inside the
column without spilling into the bar/value chip. */}
<span className="text-muted-foreground w-24 shrink-0 truncate">
{label}
</span>
<div
@@ -219,6 +386,153 @@ export function HealthScoreCard({
))}
</ul>
{/* v1.4.25 W8e — tap-to-expand provenance accordion.
Kept inside the card so the visual "owner" of the
breakdown remains the score tile (concept-cohesion).
No top border here — the components list above already
owns the divider stride; a second border would read
visually heavy. */}
<div className="-mt-1">
<button
type="button"
id={`${panelId}-toggle`}
data-slot="health-score-card-provenance-toggle"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => setExpanded((v) => !v)}
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"flex w-full items-center justify-between gap-1 rounded text-[11px]",
)}
>
<span>{t("insights.healthScore.provenance.toggle")}</span>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
expanded && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{expanded && (
<section
id={panelId}
aria-labelledby={`${panelId}-toggle`}
data-slot="health-score-card-provenance-panel"
className="mt-2 space-y-1.5"
>
{hasMixed && (
<p
data-slot="health-score-card-provenance-mixed-banner"
role="status"
className="border-dracula-yellow/30 text-dracula-yellow/90 bg-dracula-yellow/5 rounded border px-2 py-1 text-[10px] leading-snug"
>
{t("insights.healthScore.provenance.mixedBanner")}
</p>
)}
<ul
data-slot="health-score-card-provenance-rows"
className="space-y-1.5"
>
{provenanceRows.map((row) => {
const sourceLabel = t(SOURCE_LABEL_KEY[row.source]);
const isEmpty = row.source === "none" || row.value === null;
const asOfFormatted = formatAsOf(row.asOf);
const asOfLine = asOfFormatted
? t("insights.healthScore.provenance.asOfLabel", {
date: asOfFormatted,
})
: null;
return (
<li
key={row.key}
data-slot="health-score-card-provenance-row"
data-component={row.key}
data-source={row.source}
className={cn(
"flex items-center gap-2 text-[11px]",
isEmpty && "opacity-50",
)}
>
<span className="text-muted-foreground w-20 shrink-0 truncate">
{row.label}
</span>
{/* value bar — same vocabulary as the existing
sub-bar row so the two stride lines align */}
<div
className="bg-muted/50 h-1 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
isEmpty ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width: isEmpty
? "0%"
: `${Math.max(0, Math.min(100, row.value ?? 0))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-provenance-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{row.value === null ? "—" : Math.round(row.value)}
</span>
{/* weight share — second, narrower bar tinted
dracula-cyan to read "provenance grammar"
alongside the Coach <SourceChips> accent */}
<div
className="bg-muted/40 h-1 w-10 shrink-0 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
isEmpty
? "bg-muted"
: "bg-dracula-cyan/60",
)}
style={{
width: isEmpty
? "0%"
: `${Math.max(0, Math.min(100, row.weight * 100))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-provenance-pill"
aria-label={t(
"insights.healthScore.provenance.sourceAria",
{ source: sourceLabel },
)}
title={asOfLine ?? undefined}
data-as-of={row.asOf ?? undefined}
className={cn(
"inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none",
SOURCE_PILL_CLASS[row.source],
)}
>
{sourceLabel}
</span>
</li>
);
})}
</ul>
<p
data-slot="health-score-card-provenance-footnote"
className="text-muted-foreground text-[10px] leading-snug"
>
{t("insights.healthScore.provenance.footnote")}
</p>
</section>
)}
</div>
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[10px] leading-snug"
+14 -41
View File
@@ -2,14 +2,7 @@
import Link from "next/link";
import { toast } from "sonner";
import {
Download,
FileText,
Loader2,
RefreshCw,
Share2,
Sparkles,
} from "lucide-react";
import { Download, FileText, Share2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -31,9 +24,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
* - 3-button action row: "Generate weekly report" (disabled, B4),
* "Ask the coach" (disabled, B2), "Re-run analysis" (wired to
* the existing regenerate handler from v1.4.16 D-reconcile)
* - 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).
* - <SuggestedPrompts> chip strip below the action band
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
* + `.glow-purple` utilities in `globals.css`
@@ -42,8 +37,7 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
* wrap. Right-side Health Score panel is *not* part of B1 — that
* lands in B5; for B1 the right side just keeps its meta band.
*
* Pure presentational — the page owns the briefing data + the
* regenerate handler.
* Pure presentational — the page owns the briefing data.
*/
interface HeroStripProps {
@@ -57,10 +51,6 @@ interface HeroStripProps {
* morning, undefined".
*/
userName?: string | null;
/** Click handler for the regenerate button — wired to the advisor query. */
onRegenerate?: () => void;
/** Disables the regenerate button + flips its icon to a spinner. */
regenerating?: boolean;
/**
* Click handler for the suggested prompts. The Coach drawer (B2b)
* routes a chip click into the drawer's input; the parent owns the
@@ -141,8 +131,6 @@ export function HeroStrip({
briefing,
updatedAt,
userName,
onRegenerate,
regenerating = false,
onPickPrompt,
onAskCoach,
weeklyReportReady,
@@ -291,28 +279,13 @@ export function HeroStrip({
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionAskCoach")}</span>
</Button>
{onRegenerate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-hero-strip-action-rerun"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroActionRerun")}
</span>
</Button>
)}
{/*
* 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.
*/}
</div>
<div
@@ -18,6 +18,7 @@ import {
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 {
@@ -252,6 +253,7 @@ function InlineCharts({
mini?: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
if (tokens.length === 0) return null;
// Dedup while preserving the order the model emitted them.
@@ -279,9 +281,14 @@ function InlineCharts({
data-mini={mini ? "true" : "false"}
>
{kind === "mood" ? (
<MoodChart mini={mini} />
<MoodChart mini={mini} userTimezone={user?.timezone} />
) : (
<HealthChart types={[metric]} title={title} mini={mini} />
<HealthChart
types={[metric]}
title={title}
mini={mini}
userTimezone={user?.timezone}
/>
)}
</div>
);
+2 -1
View File
@@ -5,6 +5,7 @@ import { Sparkles, ArrowRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { InsightResult } from "@/lib/ai/types";
import { sortRecommendationsBySeverity } from "./recommendations-grid";
import { ConfidenceMeter } from "./confidence-meter";
@@ -101,7 +102,7 @@ export function InsightsCardPreview({
key={id ?? index}
className={`bg-card/40 flex items-start justify-between gap-2 rounded-md border-l-2 px-3 py-2 ${borderClass}`}
>
<p className="text-sm leading-snug">{text}</p>
<p className="text-sm leading-snug">{stripChartTokens(text)}</p>
{typeof confidence === "number" && (
<span className="shrink-0">
<ConfidenceMeter value={confidence} variant="ring" />
@@ -0,0 +1,36 @@
"use client";
import type { ReactNode } from "react";
import { useAuth } from "@/hooks/use-auth";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
/**
* v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`.
*
* Owns the advisor query so the regenerate button on the sticky tab
* strip works from every Insights surface (mother + the seven sub-
* pages). TanStack Query dedup keeps the cost negligible: the mother
* page's own `useInsightsAdvisorQuery` consumer shares the same
* `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
* same cache entry without extra network traffic.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const advisor = useInsightsAdvisorQuery(isAuthenticated);
return (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={isAuthenticated ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
/>
{children}
</div>
);
}
@@ -1,170 +0,0 @@
"use client";
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.16 phase B1b — Apple-Health-style page hero for `/insights`.
*
* @deprecated v1.4.20 phase B1 — superseded by `<HeroStrip>` in
* `./hero-strip.tsx`. Kept here for the dashboard preview's continued
* use; will be removed once that surface migrates.
*
* Polished header band that frames the page below. Pure presentational —
* the page passes timestamps + the regenerate handler in. Stays a thin
* shell around the existing translation keys so a future redesign only
* touches this file.
*
* Visual language:
* - subtle Dracula gradient background (purple → cyan, low opacity so
* dark-mode contrast is preserved against `var(--background)`)
* - sparkles glyph + heading + overview subtitle on the left
* - meta row underneath with personal-baseline indicator + the
* "Generated <relative-time>" caption when an updatedAt is supplied
* - regenerate button on the right (top-aligned on desktop, stacked
* under the meta row on mobile)
* - smooth fade-in via the existing `animate-insight-in` keyframes;
* `prefers-reduced-motion: reduce` already disables that animation
* globally (see globals.css §Insight card animations).
*
* Layout slots:
* - `data-slot="insights-page-hero"` — outer wrapper
* - `data-slot="insights-page-hero-generated"` — "Generated <time>"
* - `data-slot="insights-page-hero-baseline"` — "Based on …"
* - `data-slot="insights-page-hero-regenerate"` — regenerate button
* - `data-slot="insights-page-hero-meta-slot"` — v1.4.19 A3 page-
* level meta slot
* (comparison toggle)
*/
interface InsightsPageHeroProps {
/** ISO-8601 timestamp of the last AI generation; controls the "Generated …" caption. */
updatedAt?: string | null;
/** Click handler for the regenerate button; omit to hide the control. */
onRegenerate?: () => void;
/** Disables the regenerate button + flips the icon to a spinner. */
regenerating?: boolean;
/**
* v1.4.19 A3 — page-level metadata slot. The comparison-baseline
* toggle is mounted into the hero meta row (next to the personal-
* baseline indicator + the "Generated …" caption) instead of
* standing alone above the advisor card. Keeps every page-level
* control in one band so the surface stops feeling like three
* stacked cards with separate refresh / toggle / header
* affordances.
*/
metaSlot?: React.ReactNode;
}
/**
* Bucketed relative-time using i18n translation keys so every surface
* shares vocabulary. Buckets: <1m, <1h, <24h, else days.
*/
function formatRelativeTime(
iso: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return "";
const diffMs = Date.now() - target;
if (diffMs < 60_000) return t("insights.relativeJustNow");
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
const days = Math.floor(hours / 24);
return t("insights.relativeDaysAgo", { count: days });
}
export function InsightsPageHero({
updatedAt,
onRegenerate,
regenerating = false,
metaSlot,
}: InsightsPageHeroProps) {
const { t } = useTranslations();
const generatedLine = updatedAt
? t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t) })
: null;
return (
<div
data-slot="insights-page-hero"
className="border-dracula-purple/25 from-dracula-purple/15 via-dracula-cyan/8 animate-insight-in relative overflow-hidden rounded-xl border bg-gradient-to-br to-transparent px-4 py-5 sm:px-6 sm:py-6"
>
{/* Decorative sparkle glow — purely visual, kept inert for screen readers. */}
<div
aria-hidden="true"
className="bg-dracula-purple/10 pointer-events-none absolute -top-12 -right-12 h-32 w-32 rounded-full blur-3xl"
/>
<div
aria-hidden="true"
className="bg-dracula-cyan/10 pointer-events-none absolute -bottom-16 -left-10 h-36 w-36 rounded-full blur-3xl"
/>
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1 className="text-2xl font-bold tracking-tight">
{t("insights.title")}
</h1>
</div>
<p className="text-muted-foreground text-sm leading-snug">
{t("insights.overviewSubtitle")}
</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 pt-1 text-xs">
<span data-slot="insights-page-hero-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span data-slot="insights-page-hero-generated">
{generatedLine}
</span>
</>
)}
</div>
{metaSlot && (
<div
data-slot="insights-page-hero-meta-slot"
className="flex flex-wrap items-center gap-2 pt-2"
>
{metaSlot}
</div>
)}
</div>
{onRegenerate && (
<div className="flex shrink-0 items-start">
<Button
type="button"
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-page-hero-regenerate"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroRegenerate")}
</span>
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
INSIGHTS_OVERVIEW_PATH,
SUB_PAGE_SLUGS,
type SubPageSlug,
} from "@/lib/insights/sub-page-metric";
/**
* v1.4.25 W4 — routed tab strip for `/insights`.
*
* Behaviour evolved from the v1.4.25 W3 scroll-anchor version: each pill
* is now a `<Link>` to a routed sub-page (`/insights/blutdruck` …),
* `usePathname()` decides the active pill, and the strip is mounted in
* `src/app/insights/layout.tsx` so it persists across navigation
* without re-rendering. The CoachDrawer is NOT mounted in this layout —
* the drawer lives in the mother page's body only (Marc directive
* 2026-05-11).
*
* Pills:
* - "Overview" (the mother page) is the first pill and matches when
* `pathname === "/insights"` exactly.
* - The seven metric slugs each map to their own sub-route.
*
* Accessibility:
* - `<nav aria-label>` mirrors the legacy label.
* - Active pill gets `aria-current="page"`.
* - The regenerate button has an `aria-label` so the icon-only
* affordance is announced. The button only renders when
* `onRegenerate` is wired — sub-pages don't carry the regenerate
* handler today (the mother page owns the advisor query and
* forwards the trigger via the layout slot).
* - `prefers-reduced-motion` honoured at the global level (no
* scroll-into-view here — Next.js routing handles focus + scroll).
*/
export interface InsightsTabStripProps {
/**
* Optional regenerate handler. Only the mother page passes this;
* sub-pages render the strip without the right-slot button.
* Wiring the strip in `layout.tsx` means we'd need a layout-level
* advisor query — for v1.4.25 we keep the strip stateless and the
* regenerate affordance on the mother page only.
*/
onRegenerate?: () => void;
/** Spinner state — disables the button and swaps the icon. */
regenerating?: boolean;
}
interface TabEntry {
/** Pathname this pill links to. */
href: string;
/** Translation key for the pill label. */
labelKey: string;
}
function buildTabs(): TabEntry[] {
const subPages: Record<SubPageSlug, string> = {
blutdruck: "insights.navBloodPressure",
gewicht: "insights.navWeight",
puls: "insights.navPulse",
stimmung: "insights.navMood",
medikamente: "insights.navMedication",
bmi: "insights.navBmi",
schlaf: "insights.navSleep",
};
return [
{ href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" },
...SUB_PAGE_SLUGS.map((slug) => ({
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
labelKey: subPages[slug],
})),
];
}
export function InsightsTabStrip({
onRegenerate,
regenerating = false,
}: InsightsTabStripProps) {
const { t } = useTranslations();
const pathname = usePathname();
const tabs = buildTabs();
// Fire success toast on the falling edge of `regenerating`. Same
// rising-edge ref guard as the W3 implementation so the toast fires
// exactly once per regenerate cycle.
const lastRegeneratingRef = useRef<boolean>(regenerating);
useEffect(() => {
if (lastRegeneratingRef.current && !regenerating) {
toast.success(t("insights.regenerateSuccess"));
}
lastRegeneratingRef.current = regenerating;
}, [regenerating, t]);
const regenerateLabel = t("insights.regenerateAnalysis");
return (
<nav
data-slot="insights-tab-strip"
aria-label={t("insights.navAriaLabel")}
className={cn(
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
{tabs.map((tab) => {
// The overview pill matches the mother page exactly; the
// sub-page pills match a prefix so future nested routes
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
// parent tab.
const isActive =
tab.href === INSIGHTS_OVERVIEW_PATH
? pathname === INSIGHTS_OVERVIEW_PATH
: pathname === tab.href || pathname.startsWith(`${tab.href}/`);
return (
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? "page" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
// 44px touch-target floor (W8.3) — pills are primary
// navigation; the regenerate icon-button to the right
// already meets the same minimum.
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
})}
</div>
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
aria-label={regenerateLabel}
title={regenerateLabel}
data-slot="insights-tab-strip-regenerate"
className={cn(
// 44×44 touch target — same WCAG 2.5.5 floor the pill row
// now honours via `min-h-11`. The button stays circular so
// its visual weight matches the existing top-bar icon
// buttons.
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{regenerating ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<RefreshCw className="h-4 w-4" aria-hidden="true" />
)}
</button>
)}
</div>
</nav>
);
}
@@ -0,0 +1,147 @@
"use client";
/**
* v1.4.25 W16c — Personal-record "PR" pill.
*
* Renders a small, text-only "PR" tag next to a metric label when the
* given metric reached an all-time best in the last 30 days. Pulls
* directly from `GET /api/personal-records?metricType=<type>` so the
* server's `PersonalRecord` rows are the single source of truth — no
* client-side recomputation, no risk of drift against the doctor
* report's PR list.
*
* Design choices:
* - Text-only (no celebratory emoji / no animation). The badge is
* informational and the same Recharts-restrained aesthetic the
* rest of the trend tiles use.
* - Tooltip is opt-in via the `withTooltip` prop. The dashboard's
* tile strip already runs a `TooltipProvider`, so the badge can
* drop straight in. Standalone uses (correlation cards, doctor
* report) pass `withTooltip={false}` to keep the DOM minimal.
* - Query is per-metric — 14 PR-trackable metrics × ~1 KB response =
* small enough that 14 idle queries on the dashboard cost less
* than one Recharts re-render. TanStack Query coalesces.
*/
import { useQuery } from "@tanstack/react-query";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
interface PersonalRecordRow {
id: string;
metricType: MeasurementType;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
}
interface PersonalRecordBadgeProps {
metricType: MeasurementType;
/** Render a wrapping Tooltip with the PR value + date. Default true. */
withTooltip?: boolean;
/** Optional className passthrough so callers can position the pill. */
className?: string;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Pure freshness check — exported for unit testing so the component
* itself stays free of clock calls (the React purity linter rejects
* `Date.now()` inside a component body or hook initializer). Callers
* pass a `now` reference; consumers in production read it from the
* surrounding query refetch boundary where one stale-time tick =
* one new clock reading.
*/
export function isPRWithin30Days(achievedAtIso: string, now: number): boolean {
const ts = new Date(achievedAtIso).getTime();
return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS;
}
export function PersonalRecordBadge({
metricType,
withTooltip = true,
className,
}: PersonalRecordBadgeProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, dataUpdatedAt } = useQuery({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
queryKey: ["personal-records", "by-metric", metricType],
queryFn: async () => {
const res = await fetch(
`/api/personal-records?metricType=${encodeURIComponent(metricType)}`,
);
if (!res.ok) return [] as PersonalRecordRow[];
return ((await res.json()).data ?? []) as PersonalRecordRow[];
},
// Per-tile data: stale-while-revalidate cadence matches the
// dashboard's existing trend queries. 5 minutes is enough to
// absorb the worker's 30-minute fallback cadence without
// surfacing stale state for long.
staleTime: 5 * 60 * 1000,
});
// `dataUpdatedAt` is TanStack Query's per-query refetch timestamp
// — a deterministic clock reading that React's purity linter
// accepts because it's hook-derived. Each refetch advances it,
// shifting the freshness window forward by exactly the stale time.
// On the very first render (before the query has resolved)
// `dataUpdatedAt` is 0, so the freshness filter rejects everything
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
row.metricSlot === null && isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
const pill = (
<span
data-slot="insights-pr-badge"
data-metric-type={metricType}
className={cn(
// `--success` resolves to the AA-tuned green per theme: a deep
// forest green on the light card (#14720a) and Dracula's neon
// green on the dark card (#50fa7b). Both clear 4.5:1 against
// their card background — the previous `text-dracula-green` on
// `bg-dracula-green/10` measured ~2.6:1 in dark mode.
"border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide tabular-nums uppercase",
className,
)}
aria-label={t("insights.personalRecord.badge")}
>
{t("insights.personalRecord.badge")}
</span>
);
if (!withTooltip) return pill;
const formattedDate = fmt.date(new Date(fresh.achievedAt));
const tooltipText = t("insights.personalRecord.tooltip", {
value: fmt.number(fresh.value, 1),
unit: fresh.unit,
date: formattedDate,
});
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent
className="bg-muted border-border text-foreground"
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
</TooltipContent>
</Tooltip>
);
}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import type {
InsightRecommendation,
InsightRecommendationRationale,
@@ -194,6 +195,7 @@ function RationaleCard({
} | null;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const metricType = metricSource?.type;
const chartTypes = metricTypeToChartTypes(metricType);
const showLowConfidenceCaption =
@@ -208,15 +210,15 @@ function RationaleCard({
<div className="space-y-1.5">
<RationaleRow
label={t("insights.recommendation.rationaleWindow")}
value={rationale.dataWindow}
value={stripChartTokens(rationale.dataWindow)}
/>
<RationaleRow
label={t("insights.recommendation.rationaleComparedTo")}
value={rationale.comparedTo}
value={stripChartTokens(rationale.comparedTo)}
/>
<RationaleRow
label={t("insights.recommendation.rationaleDeviation")}
value={rationale.deviation}
value={stripChartTokens(rationale.deviation)}
/>
</div>
@@ -224,13 +226,18 @@ function RationaleCard({
compliance get dedicated wrappers; everything else routes
through HealthChart with the metric-key vocabulary. */}
{isMoodMetric(metricType) ? (
<MoodChart mini windowOverride={rationale.dataWindow} />
<MoodChart
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
<HealthChart
types={chartTypes}
title={metricType ?? ""}
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
) : null}
@@ -0,0 +1,54 @@
"use client";
import dynamic from "next/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
* 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
* pre-aggregated `/api/measurements/series` payload, so we pass the
* raw type through and let the existing chart wire do the day-rollup.
*
* Unit semantics: SLEEP_DURATION is canonically stored in MINUTES
* (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.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
export interface SleepDurationChartProps {
compareBaseline?: ComparisonBaseline;
userTimezone?: string;
}
export function SleepDurationChart({
compareBaseline,
userTimezone,
}: SleepDurationChartProps) {
const { t } = useTranslations();
return (
<HealthChart
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("charts.sleep")}
colors={["#8be9fd"]}
unit="min"
yAxisUnit="min"
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "./sleep-stage-stacked-bar";
import { SleepDurationChart } from "./sleep-duration-chart";
/**
* v1.4.25 W4c — Sleep insights composition.
*
* Pulls the analytics `sleepStages` aggregate (added in v1.4.23 W2)
* plus the SLEEP_DURATION trend series via `<SleepDurationChart>` and
* renders three blocks:
*
* 1. Headline card with the average nightly total (h / min) and the
* number of nights covered.
* 2. Stage composition stacked bar (REM / Deep / Core / Awake / In
* bed / Asleep).
* 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`).
*
* Empty state: no SLEEP_DURATION rows yet → render the "Apple Health
* sync is coming in v1.5" message with a deep-link to Settings →
* Devices so the user can prepare. We do NOT render any of the three
* blocks in that case — half-rendered empty cards would be worse than
* a single clean CTA.
*/
interface SleepAnalyticsSummary {
latest: number | null;
avg7: number | null;
avg30: number | null;
count: number;
}
interface SleepAnalyticsResponse {
summaries: {
SLEEP_DURATION?: SleepAnalyticsSummary | null;
};
sleepStages: SleepStageBreakdown | null;
}
function formatHoursMinutes(
totalMinutes: number,
locale: string,
): { primary: string } {
const hours = Math.floor(totalMinutes / 60);
const mins = Math.round(totalMinutes - hours * 60);
if (locale === "de") {
return { primary: `${hours} Std. ${mins} Min.` };
}
return { primary: `${hours}h ${mins}m` };
}
export function SleepOverview() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data, isLoading } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed to load analytics");
const json = await res.json();
return json.data as SleepAnalyticsResponse;
},
enabled: isAuthenticated,
});
const sleepSummary = data?.summaries?.SLEEP_DURATION ?? null;
const sleepStages = data?.sleepStages ?? null;
const totalCount = sleepSummary?.count ?? 0;
if (!isLoading && totalCount === 0) {
return (
<EmptyState
icon={<Moon className="size-6" />}
title={t("insights.sleep.empty.title")}
description={t("insights.sleep.empty.description")}
action={
<Button size="sm" variant="outline" asChild>
<Link href="/settings/devices">
{t("insights.sleep.empty.action")}
</Link>
</Button>
}
/>
);
}
const averageMinutes = sleepSummary?.avg30 ?? sleepSummary?.avg7 ?? null;
const suffix = t("insights.sleep.headlineCaptionSuffix");
const { primary } =
averageMinutes != null
? formatHoursMinutes(averageMinutes, locale)
: { primary: "—" };
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-sm font-medium">
{t("insights.sleep.headlineTitle")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
<p className="text-2xl font-semibold">{primary}</p>
<p className="text-muted-foreground text-xs">
{sleepSummary?.count
? t("insights.sleep.headlineCaption", {
count: sleepSummary.count,
suffix,
})
: suffix}
</p>
</div>
</CardContent>
</Card>
{sleepStages ? (
<SleepStageStackedBar breakdown={sleepStages} />
) : (
<Card>
<CardContent className="text-muted-foreground py-6 text-sm">
{t("insights.sleep.stages.unavailable")}
</CardContent>
</Card>
)}
<SleepDurationChart
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
</div>
);
}
@@ -0,0 +1,352 @@
"use client";
import { useState, useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W4c → W3f — sleep-stage composition chart.
*
* Marc directive 2026-05-14: switch from "30-day average composition"
* to a per-night stacked column chart so the user sees nightly stage
* variation, not just a rolling average. Apple Health's sleep tab is
* the visual reference — one column per night, stacks for REM / Deep /
* Core / Awake.
*
* Window toggle: 7 / 14 / 30 days, default 7. The toggle pill above
* the chart matches the per-chart cog pattern in the rest of the app.
* Backed by `/api/analytics`'s `sleepStages.perNight` field (added in
* v1.4.25 W3f); the parent threads the full per-night array and the
* chart slices it down to the active window.
*
* Accessibility: the Recharts wrapper sets `role="img"` and an
* `aria-label` derived from the composition so screen readers hear a
* meaningful summary instead of a forest of `<rect>` elements.
*/
export interface SleepStageNight {
/** Berlin-tz day key (YYYY-MM-DD). */
dayKey: string;
stages: Record<string, number>;
}
export interface SleepStageBreakdown {
windowDays: number;
nights: number;
totalMinutes: number;
/**
* Keys mirror the Prisma `SleepStage` enum:
* IN_BED, AWAKE, ASLEEP, REM, CORE, DEEP.
* Values are total minutes across the trailing-30 window.
*/
stages: Record<string, number>;
/**
* v1.4.25 W3f — per-night breakdown over the trailing 30 days,
* sorted ascending. The chart slices the trailing N entries based on
* the active window toggle.
*/
perNight?: SleepStageNight[];
}
export interface SleepStageStackedBarProps {
breakdown: SleepStageBreakdown;
}
/** Window toggle values. Default 7. */
const WINDOW_DAYS = [7, 14, 30] as const;
type WindowSize = (typeof WINDOW_DAYS)[number];
/**
* Order on the stack — deepest restorative stages first so a user
* scanning left → right reads quality before context.
*/
const STAGE_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
"IN_BED",
] as const;
const STAGE_COLORS: Record<string, string> = {
DEEP: "#bd93f9", // dracula-purple — deepest, most restorative
REM: "#ff79c6", // dracula-pink — dream phase
CORE: "#8be9fd", // dracula-cyan — bulk of sleep
ASLEEP: "#50fa7b", // dracula-green — legacy iOS 15- unspecified
AWAKE: "#f1fa8c", // dracula-yellow — wake bouts
IN_BED: "#6272a4", // dracula-comment — pre-asleep
};
function formatMinutes(total: number, locale: string): string {
const hours = Math.floor(total / 60);
const mins = Math.round(total - hours * 60);
if (locale === "de") {
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
}
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
/**
* Format a Berlin-tz day key (YYYY-MM-DD) as a short x-axis tick.
* 7-day window → "Mon" / "Tue" / …; 14-day → "M 10" / "T 11" / …;
* 30-day → "May 10" / "May 11" / … Recharts handles overflow via
* interval=preserveStartEnd so we keep the label space tight without
* forced rotation.
*
* The constructed Date is anchored to UTC so the rendered tick is
* stable regardless of the SSR server's local timezone — without the
* anchor a user in Asia/Tokyo viewing a server rendered in
* Europe/Berlin could see the weekday tick shift by one. We pair the
* UTC anchor with `timeZone: "UTC"` on the locale formatter so both
* sides of the conversion agree.
*/
function formatDayTick(
dayKey: string,
window: WindowSize,
locale: string,
): string {
const [y, m, d] = dayKey.split("-").map(Number);
if (!y || !m || !d) return dayKey;
const date = new Date(Date.UTC(y, m - 1, d));
if (window === 7) {
return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", {
timeZone: "UTC",
weekday: "short",
});
}
if (window === 14) {
return `${date.getUTCDate()}.`;
}
return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", {
timeZone: "UTC",
month: "short",
day: "numeric",
});
}
export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
const { t, locale } = useTranslations();
// v1.4.25 W3f — window toggle (7 / 14 / 30). Default 7d so the user
// sees their most recent week with maximal per-bar resolution.
const [windowDays, setWindowDays] = useState<WindowSize>(7);
// Pull stage names from i18n once so the legend + tooltip share a
// single source of truth.
const stageLabels: Record<string, string> = {
DEEP: t("insights.sleep.stages.deep"),
REM: t("insights.sleep.stages.rem"),
CORE: t("insights.sleep.stages.core"),
ASLEEP: t("insights.sleep.stages.asleep"),
AWAKE: t("insights.sleep.stages.awake"),
IN_BED: t("insights.sleep.stages.inBed"),
};
// v1.4.25 W3f — per-night dataset. Slice the trailing N nights and
// build a Recharts row per night with one numeric key per stage.
// Empty perNight (legacy clients during the rollout) falls back to
// the aggregate row so the chart still renders something rather
// than going blank.
const data = useMemo(() => {
const perNight = breakdown.perNight ?? [];
if (perNight.length === 0) {
// Legacy fallback: render the 30-day aggregate as a single row
// so the chart degrades gracefully against a pre-W3f payload.
const fallbackRow: Record<string, number | string> = {
dayKey: "aggregate",
label: t("insights.sleep.compositionTitle"),
};
for (const stage of STAGE_ORDER) {
fallbackRow[stage] = breakdown.stages[stage] ?? 0;
}
return [fallbackRow];
}
const trailing = perNight.slice(-windowDays);
return trailing.map((night) => {
const row: Record<string, number | string> = {
dayKey: night.dayKey,
label: formatDayTick(night.dayKey, windowDays, locale),
};
for (const stage of STAGE_ORDER) {
row[stage] = night.stages[stage] ?? 0;
}
return row;
});
}, [breakdown, windowDays, locale, t]);
// Empty-state guard — no perNight rows AND no aggregate.
const hasData =
data.length > 0 &&
data.some((row) =>
STAGE_ORDER.some(
(stage) => typeof row[stage] === "number" && (row[stage] as number) > 0,
),
);
const ariaLabel = t("insights.sleep.compositionAriaLabel", {
nights: breakdown.nights,
});
return (
<Card data-slot="sleep-stage-stacked-bar">
<CardHeader className="pb-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-sm font-medium">
{t("insights.sleep.compositionTitle")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.sleep.compositionSubtitle", {
nights: breakdown.nights,
})}
</span>
</div>
<div
className="flex items-center gap-1 self-end sm:self-auto"
data-slot="sleep-stage-window-toggle"
>
{WINDOW_DAYS.map((w) => (
<Button
key={w}
type="button"
variant={windowDays === w ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setWindowDays(w)}
aria-pressed={windowDays === w}
data-slot={`sleep-stage-window-${w}`}
>
{w}d
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{!hasData ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
data-slot="sleep-stage-empty"
>
{t("insights.sleep.stages.unavailable")}
</p>
) : (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={data}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
<XAxis
type="category"
dataKey="label"
stroke="var(--muted-foreground)"
fontSize={10}
interval="preserveStartEnd"
/>
<YAxis
type="number"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => {
// Render the y-axis as hours so 480 min reads as 8h.
if (v <= 0) return "0";
return `${Math.round(v / 60)}h`;
}}
/>
<Tooltip
cursor={{ fill: "transparent" }}
content={({ active, payload, label }) => {
if (!active || !payload || payload.length === 0)
return null;
const totalNight = payload.reduce(
(sum, entry) =>
sum +
(typeof entry.value === "number" ? entry.value : 0),
0,
);
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
<div className="border-border mb-1 border-b pb-1 font-medium">
{label}
</div>
{payload.map((entry) => {
const stage = String(entry.dataKey ?? "");
const minutes =
typeof entry.value === "number" ? entry.value : 0;
if (minutes === 0) return null;
const pct =
totalNight > 0
? Math.round((minutes / totalNight) * 100)
: 0;
return (
<div
key={stage}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{stageLabels[stage] ?? stage}
</span>
<span className="text-muted-foreground">
{formatMinutes(minutes, locale)} · {pct}%
</span>
</div>
);
})}
{totalNight > 0 && (
<div className="border-border mt-1 flex items-center justify-between gap-3 border-t pt-1 font-medium">
<span>{t("insights.sleep.headlineTitle")}</span>
<span>{formatMinutes(totalNight, locale)}</span>
</div>
)}
</div>
);
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => stageLabels[value] ?? value}
/>
{STAGE_ORDER.map((stage) => (
<Bar
key={stage}
dataKey={stage}
stackId="stages"
fill={STAGE_COLORS[stage]}
isAnimationActive={false}
>
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* v1.4.25 W4 — common visual frame for each metric sub-page.
*
* Each sub-page renders:
* - a calm header (title + optional status badge),
* - the canonical chart for the metric (passed in as `chart` so the
* page can wire the right `chartKey`, target zones, value bands, …),
* - secondary slot for related cards (correlation cards, per-medication
* lists, …).
*
* The shell handles focus restoration on route change — a screen-reader
* user landing here from a tab click hears the page heading
* automatically because we mount an `id="insights-subpage-title"`
* h1 with `tabIndex={-1}` and focus it on mount. Sighted users get a
* `scrollTo({ top: 0 })` so deep-scroll position from the previous
* sub-page doesn't leak in.
*/
export interface SubPageShellProps {
title: string;
/** Optional accent badge (status, dataset coverage, …). */
badge?: ReactNode;
/**
* Optional short paragraph beneath the title, mirroring the Apple-Health
* convention of a one-line scaffold on every metric page. The mother
* page does not pass it; sub-pages use it to anchor the surface.
*/
description?: string;
children: ReactNode;
}
export function SubPageShell({
title,
badge,
description,
children,
}: SubPageShellProps) {
// a11y: focus the heading on mount so a tab-strip navigation actually
// moves screen-reader focus into the sub-page body. The `scrollTo`
// pairs with that so sighted users don't see the previous page's
// deep-scroll position bleed in. Both are gated on
// `prefers-reduced-motion: reduce` (we honour the OS pref by using
// `scroll-behavior: auto` on the parent, which Tailwind already wires
// via the reduced-motion utilities — `scrollTo({ behavior: "auto" })`
// mirrors that contract intentionally).
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, behavior: "auto" });
}
headingRef.current?.focus({ preventScroll: true });
}, []);
return (
<div data-slot="insights-subpage" className="space-y-6">
<header className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h1
ref={headingRef}
id="insights-subpage-title"
tabIndex={-1}
className={cn(
"text-xl font-semibold sm:text-2xl",
// a11y: sighted-keyboard users (e.g. "Skip to content")
// need a visible focus indicator on the programmatic
// `headingRef.focus()` call below. Match the focus ring
// vocabulary used on insights pills + Coach affordances.
"rounded-sm focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
{title}
</h1>
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}
</Badge>
) : null}
</div>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{children}
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, Pill, Syringe } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
describeInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — therapy-timeline view for /insights/medikamente.
*
* Renders a chronological list of GLP-1 therapy events: dose changes,
* injections (with site if recorded), inventory events, side-effect tag
* occurrences. Pure list view — no chart — so the medications-page
* "no chart on the medication card" rule stays consistent across all
* GLP-1 surfaces; the dose curve lives on the Dashboard tile only.
*
* Self-hides for users with no GLP-1 medications (the parent route does
* the gating via a non-200 from the API endpoint).
*/
interface TimelineEntry {
date: string;
kind: "dose-change" | "injection" | "inventory" | "side-effect";
medicationName?: string;
doseValue?: number;
doseUnit?: string;
doseDelta?: "up" | "down" | null;
note?: string | null;
injectionSite?: InjectionSiteKey | null;
inventoryDelta?: number;
reason?: string;
tags?: string[];
}
interface TherapyTimelineResponse {
hasGlp1: boolean;
entries: TimelineEntry[];
}
interface TherapyTimelineProps {
/** Optional cap — defaults to the API's default (60 entries). */
limit?: number;
}
export function TherapyTimeline({ limit }: TherapyTimelineProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading } = useQuery({
queryKey: ["insights", "glp1-timeline", limit ?? "default"],
queryFn: async () => {
const url = limit
? `/api/insights/glp1-timeline?limit=${limit}`
: "/api/insights/glp1-timeline";
const res = await fetch(url);
if (!res.ok) return null;
const json = await res.json();
return json.data as TherapyTimelineResponse;
},
staleTime: 60 * 1000,
});
if (isLoading || !data || !data.hasGlp1) return null;
if (data.entries.length === 0) {
return (
<Card data-slot="therapy-timeline">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Syringe className="text-dracula-purple h-4 w-4" />
{t("insights.therapyTimeline.title")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.therapyTimeline.empty")}
</p>
</CardContent>
</Card>
);
}
return (
<Card data-slot="therapy-timeline">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Syringe className="text-dracula-purple h-4 w-4" />
{t("insights.therapyTimeline.title")}
</CardTitle>
<p className="text-muted-foreground text-xs">
{t("insights.therapyTimeline.description")}
</p>
</CardHeader>
<CardContent>
<ol className="border-border/60 relative space-y-3 border-l pl-4">
{data.entries.map((entry) => (
<li
key={`${entry.kind}-${entry.date}-${entry.medicationName ?? ""}`}
className="relative"
>
<span
className="border-background absolute top-1 -left-[18px] h-3 w-3 rounded-full border-2"
aria-hidden="true"
style={{ backgroundColor: colourForKind(entry.kind) }}
/>
<div className="space-y-0.5">
<p className="text-muted-foreground text-xs">
{fmt.dateWithWeekday(new Date(entry.date))}
</p>
<p className="text-foreground/90 text-sm">
{renderEntryLine(entry, t)}
</p>
{entry.note && (
<p className="text-muted-foreground text-xs italic">
{entry.note}
</p>
)}
</div>
</li>
))}
</ol>
</CardContent>
</Card>
);
}
function colourForKind(kind: TimelineEntry["kind"]): string {
switch (kind) {
case "dose-change":
return "var(--dracula-purple)";
case "injection":
return "var(--dracula-cyan)";
case "inventory":
return "var(--dracula-yellow)";
case "side-effect":
return "var(--dracula-orange)";
}
}
function renderEntryLine(
entry: TimelineEntry,
t: (key: string, params?: Record<string, string | number>) => string,
): React.ReactNode {
const drug = entry.medicationName ?? "";
switch (entry.kind) {
case "dose-change": {
const arrow =
entry.doseDelta === "up" ? (
<ArrowUp className="inline h-3 w-3" aria-hidden="true" />
) : entry.doseDelta === "down" ? (
<ArrowDown className="inline h-3 w-3" aria-hidden="true" />
) : null;
return (
<span>
<strong>{drug}</strong> {t("insights.therapyTimeline.doseTo")}{" "}
<span className="font-medium tabular-nums">
{entry.doseValue} {entry.doseUnit}
</span>
{arrow && <> {arrow}</>}
</span>
);
}
case "injection": {
const site = entry.injectionSite
? ` · ${t(describeInjectionSite(entry.injectionSite))}`
: "";
return (
<span>
<Pill
className="text-dracula-cyan mr-1 inline h-3 w-3"
aria-hidden="true"
/>
<strong>{drug}</strong>
{site}
</span>
);
}
case "inventory": {
const sign = entry.inventoryDelta && entry.inventoryDelta > 0 ? "+" : "";
return (
<span>
<strong>{drug}</strong> · {sign}
{entry.inventoryDelta} {t("insights.therapyTimeline.pens")} (
{entry.reason})
</span>
);
}
case "side-effect": {
return (
<span>
{t("insights.therapyTimeline.sideEffects")}:{" "}
{(entry.tags ?? []).join(", ")}
</span>
);
}
}
}
+4 -1
View File
@@ -3,6 +3,7 @@
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
@@ -73,7 +74,9 @@ export function TrendAnnotation({
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-foreground text-xs leading-snug">{annotation}</p>
<p className="text-foreground text-xs leading-snug">
{stripChartTokens(annotation)}
</p>
{confidence ? (
<Badge
data-slot="trend-annotation-confidence"
+20 -7
View File
@@ -2,6 +2,7 @@
import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
@@ -75,6 +76,8 @@ interface TrendsRowProps {
export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTimezone = user?.timezone;
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
@@ -98,13 +101,17 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
three cards on visibly different baselines. Each card is now
a flex column with `min-h-[300px]` so the chart anchors to
the top and the annotation grows downward into a shared
minimum height. The grid + min-h together preserve the visual
rhythm even on a row where only one annotation is multi-line. */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
minimum height.
v1.4.25 W3 — strengthen the equal-height contract with
`md:auto-rows-fr` + `md:items-stretch` on the grid and
`h-full` on each card so the tallest annotation still pins
every row member to a single baseline. The `min-h-[300px]`
floor stays in place for the single-column mobile view. */}
<div className="grid grid-cols-1 gap-4 md:auto-rows-fr md:grid-cols-3 md:items-stretch">
<div
data-slot="trends-row-card"
data-metric="bp"
className="flex min-h-[300px] flex-col gap-2"
className="flex h-full min-h-[300px] flex-col gap-2"
>
<HealthChart
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
@@ -113,6 +120,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
unit="mmHg"
yAxisUnit="Hg"
mini
userTimezone={userTimezone}
/>
<TrendAnnotation
metric="bp"
@@ -123,7 +131,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
<div
data-slot="trends-row-card"
data-metric="weight"
className="flex min-h-[300px] flex-col gap-2"
className="flex h-full min-h-[300px] flex-col gap-2"
>
<HealthChart
types={["WEIGHT"]}
@@ -131,6 +139,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
colors={["#bd93f9"]}
unit="kg"
mini
userTimezone={userTimezone}
/>
<TrendAnnotation
metric="weight"
@@ -141,9 +150,13 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
<div
data-slot="trends-row-card"
data-metric="mood"
className="flex min-h-[300px] flex-col gap-2"
className="flex h-full min-h-[300px] flex-col gap-2"
>
<MoodChart title={t("charts.mood")} mini />
<MoodChart
title={t("charts.mood")}
mini
userTimezone={userTimezone}
/>
<TrendAnnotation
metric="mood"
annotation={moodAnnotation}
@@ -0,0 +1,223 @@
"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";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`.
*
* The dashboard already carries an opt-in VO2 max trend tile (W8d.5);
* this is the deeper Insights surface. Renders one HealthChart for the
* trend plus a compact stat strip:
*
* - latest value with the personalised target unit
* - min / max across the underlying samples
* - 30-day average and the comparison delta vs. the prior period
*
* 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.
*/
// 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
* the parent doesn't have to gate on the network read finishing — the
* row falls back to the empty-state hint until the data lands.
*/
summary: DataSummary | null;
/**
* Comparison baseline propagated from the user's Insights layout pref
* so the chart's reference line + the in-card delta caption line up.
*/
compareBaseline?: ComparisonBaseline;
/** User's IANA timezone string — propagates to HealthChart formatting. */
userTimezone?: string;
}
export function Vo2MaxChartRow({
summary,
compareBaseline = "none",
userTimezone,
}: Vo2MaxChartRowProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const latest = summary?.latest ?? null;
const min = summary?.min ?? null;
const max = summary?.max ?? null;
const avg30 = summary?.avg30 ?? null;
const slope30 = summary?.slope30 ?? null;
const count = summary?.count ?? 0;
// 30-day delta. Mirrors the dashboard's `tileCompareDelta` rule:
// suppressed when comparison is off OR either side has no data.
const compareDelta = ((): number | null => {
if (!summary || compareBaseline === "none") return null;
const current = summary.avg30 ?? null;
const prior =
compareBaseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
: (summary.avg30LastYear ?? null);
if (current === null || prior === null) return null;
return Math.round((current - prior) * 100) / 100;
})();
// Higher VO2 max is better → up-good sentiment (mirrors the dashboard
// tile). Determines arrow + delta colour.
const TrendIcon =
slope30?.direction === "up"
? ArrowUp
: slope30?.direction === "down"
? ArrowDown
: slope30
? ArrowRight
: Minus;
const trendColor = ((): string => {
if (!slope30 || slope30.direction === "stable")
return "text-muted-foreground";
return slope30.direction === "up"
? "text-dracula-green"
: "text-dracula-orange";
})();
const formatValue = (value: number | null): string =>
value === null ? "—" : fmt.number(value, 1);
const formatDelta = (value: number): string => {
if (Math.abs(value) < 0.05) return "±0";
const sign = value > 0 ? "+" : "−";
return `${sign}${fmt.number(Math.abs(value), 1)}`;
};
const unit = t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)";
const title = t("dashboard.vo2Max") ?? "VO₂ max";
const hasData = count > 0;
// Color the comparison delta with the same up-good sentiment rules.
const compareColor = ((): string => {
if (compareDelta === null || Math.abs(compareDelta) < 0.05) {
return "text-muted-foreground";
}
return compareDelta > 0 ? "text-dracula-green" : "text-dracula-orange";
})();
return (
<section
data-slot="vo2-chart-row"
aria-label={title}
className="space-y-3"
>
<div className="flex items-baseline justify-between gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Gauge className="text-muted-foreground h-4 w-4" aria-hidden="true" />
{title}
</h2>
<p className="text-muted-foreground text-xs">{unit}</p>
</div>
{/* Stat strip — latest / min / max / avg30 / Δ vs. prior period. */}
<div
data-slot="vo2-chart-row-stats"
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 sm:grid-cols-4"
>
<div data-slot="vo2-stat" data-stat="latest" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statLatest") ?? "Latest"}
</p>
<p className="flex items-baseline gap-1 text-xl font-semibold tabular-nums">
<span>{formatValue(latest)}</span>
{slope30 ? (
<TrendIcon
className={cn("h-4 w-4 shrink-0", trendColor)}
aria-hidden="true"
/>
) : null}
</p>
</div>
<div data-slot="vo2-stat" data-stat="min" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statMin") ?? "Min"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(min)}
</p>
</div>
<div data-slot="vo2-stat" data-stat="max" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statMax") ?? "Max"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(max)}
</p>
</div>
<div data-slot="vo2-stat" data-stat="avg30" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("charts.avg30dShort") ?? "30d"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(avg30)}
</p>
{compareDelta !== null ? (
<p
data-slot="vo2-compare-delta"
data-compare-baseline={compareBaseline}
className={cn(
"text-xs font-medium tabular-nums",
compareColor,
)}
>
{`Δ ${formatDelta(compareDelta)} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</p>
) : null}
</div>
</div>
{/* Trend chart. The chartKey isolates per-chart overlay prefs from
the dashboard VO2 tile and from the pulse chart sitting above
it on the same page. */}
{hasData ? (
<HealthChart
chartKey="vo2Max"
types={["VO2_MAX"]}
title={title}
colors={["#50fa7b"]}
unit={unit}
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
) : (
<p
data-slot="vo2-chart-row-empty"
className="text-muted-foreground bg-muted/30 rounded-md border border-dashed p-6 text-center text-sm italic"
>
{t("insights.vo2Max.empty") ??
"No VO₂ max data yet — sync Apple Health to populate this view."}
</p>
)}
</section>
);
}
@@ -16,6 +16,7 @@ import {
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";
@@ -189,7 +190,9 @@ export function WeeklyReportPresentation({
title={t("insights.report.sectionSummary")}
accent="text-dracula-purple"
>
<p className="text-sm leading-relaxed">{report.summary}</p>
<p className="text-sm leading-relaxed">
{stripChartTokens(report.summary)}
</p>
</Section>
{/* Going well */}
@@ -200,7 +203,7 @@ export function WeeklyReportPresentation({
accent="text-dracula-green"
icon={<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.goingWell} />
<BulletList items={report.goingWell.map(stripChartTokens)} />
</Section>
)}
@@ -212,7 +215,7 @@ export function WeeklyReportPresentation({
accent="text-dracula-orange"
icon={<Eye className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.worthWatching} />
<BulletList items={report.worthWatching.map(stripChartTokens)} />
</Section>
)}
@@ -224,7 +227,7 @@ export function WeeklyReportPresentation({
accent="text-dracula-cyan"
icon={<Lightbulb className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.tips} />
<BulletList items={report.tips.map(stripChartTokens)} />
</Section>
)}
@@ -236,7 +239,7 @@ export function WeeklyReportPresentation({
accent="text-muted-foreground"
>
<p className="text-muted-foreground text-sm leading-relaxed">
{report.dataQualityNotes}
{stripChartTokens(report.dataQualityNotes)}
</p>
</Section>
)}
+16 -8
View File
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { BottomNav } from "./bottom-nav";
@@ -61,12 +62,15 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
// Public pages (login, register) — render without any nav
if (isPublicPage) {
return (
<main
id="main-content"
className="flex h-dvh items-center justify-center px-4"
>
{children}
</main>
<div className="flex h-dvh flex-col">
<MaintainershipBanner />
<main
id="main-content"
className="flex flex-1 items-center justify-center px-4"
>
{children}
</main>
</div>
);
}
@@ -87,8 +91,11 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
<div className="flex min-h-dvh items-center justify-center px-4 py-8">
{children}
<div className="flex min-h-dvh flex-col">
<MaintainershipBanner />
<div className="flex flex-1 items-center justify-center px-4 py-8">
{children}
</div>
</div>
</>
);
@@ -117,6 +124,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
<div className="flex h-dvh flex-col md:flex-row">
<SidebarNav />
<div className="flex min-h-0 flex-1 flex-col">
<MaintainershipBanner />
<TopBar />
<main
id="main-content"
+5 -2
View File
@@ -61,7 +61,9 @@ export function TopBar() {
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus:outline-none"
// v1.4.25 W8 — hit the WCAG 2.5.5 44 px touch-target floor on
// mobile. The text-only py-1.5 was previously ~30 px tall.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus:outline-none"
>
<User className="h-4 w-4" />
<span className="hidden sm:inline">{user.username}</span>
@@ -128,7 +130,8 @@ export function TopBar() {
) : (
<Link
href="/auth/login"
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-sm transition-colors"
// v1.4.25 W8 — match the WCAG 2.5.5 44 px touch-target floor.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-2 rounded-md px-2 text-sm transition-colors"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">{t("nav.login")}</span>
@@ -29,6 +29,10 @@ import {
TrendingUp,
Thermometer,
Gauge,
Dumbbell,
Volume2,
Headphones,
Sun,
type LucideIcon,
} from "lucide-react";
@@ -52,6 +56,18 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
VO2_MAX: "measurements.typeVo2Max",
BODY_TEMPERATURE: "measurements.typeBodyTemperature",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "measurements.typeFatFreeMass",
FAT_MASS: "measurements.typeFatMass",
MUSCLE_MASS: "measurements.typeMuscleMass",
SKIN_TEMPERATURE: "measurements.typeSkinTemperature",
PULSE_WAVE_VELOCITY: "measurements.typePulseWaveVelocity",
VASCULAR_AGE: "measurements.typeVascularAge",
VISCERAL_FAT: "measurements.typeVisceralFat",
// ── v1.4.25 W8d Apple Health server-prep ──
AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv",
AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone",
TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -74,6 +90,26 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
WALKING_RUNNING_DISTANCE: Footprints,
VO2_MAX: Gauge,
BODY_TEMPERATURE: Thermometer,
// ── v1.4.25 W5d Withings full coverage ──
// Body-composition trio: Scale carries the mass-family (FFM is the
// weight residual after fat); Droplets carries the fat-family
// (BODY_FAT already uses it, so FAT_MASS + VISCERAL_FAT match);
// Dumbbell is reserved for muscle so the three rows stay distinct
// in the list view.
FAT_FREE_MASS: Scale,
FAT_MASS: Droplets,
MUSCLE_MASS: Dumbbell,
SKIN_TEMPERATURE: Thermometer,
PULSE_WAVE_VELOCITY: Activity,
VASCULAR_AGE: HeartPulse,
VISCERAL_FAT: Droplets,
// ── v1.4.25 W8d Apple Health server-prep ──
// Volume2 carries the ambient-audio family (concert/traffic icon
// convention), Headphones is the obvious AirPods-listening cue, and
// Sun mirrors Apple Health's own time-in-daylight tile.
AUDIO_EXPOSURE_ENV: Volume2,
AUDIO_EXPOSURE_HEADPHONE: Headphones,
TIME_IN_DAYLIGHT: Sun,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -96,4 +132,21 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
VO2_MAX: "bg-chart-1/20 text-chart-1",
BODY_TEMPERATURE: "bg-chart-4/20 text-chart-4",
// ── v1.4.25 W5d Withings full coverage ──
// chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5
// (pulse-derived) — extend the existing color-family conventions.
FAT_FREE_MASS: "bg-chart-1/20 text-chart-1",
FAT_MASS: "bg-chart-4/20 text-chart-4",
MUSCLE_MASS: "bg-chart-1/20 text-chart-1",
SKIN_TEMPERATURE: "bg-chart-4/20 text-chart-4",
PULSE_WAVE_VELOCITY: "bg-chart-5/20 text-chart-5",
VASCULAR_AGE: "bg-chart-3/20 text-chart-3",
VISCERAL_FAT: "bg-chart-4/20 text-chart-4",
// ── v1.4.25 W8d Apple Health server-prep ──
// chart-5 (pulse / sound family) carries audio exposure; chart-2
// (activity / daylight family) carries time-in-daylight so the
// existing palette conventions hold.
AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5",
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5",
TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2",
};
@@ -0,0 +1,537 @@
"use client";
/**
* v1.4.25 W19c-Frontend — Estimated drug-level chart for GLP-1
* 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.
*
* Inputs (read-only):
* - Drug id resolved from the medication name (catalog lookup via
* `findDrugByBrand`); falls back to a gated message when the
* medication isn't in the GLP-1 catalog.
* - Intake events from `GET /api/medications/[id]/intake` — only
* non-skipped events with a `takenAt` contribute.
* - Dose history from `GET /api/medications/[id]/glp1` (the
* `doseChanges` slice). Each intake event is matched to its
* applicable dose by walking the dose-change timeline; intakes
* that pre-date every dose-change row fall back to the
* medication's headline dose string.
* - Research-mode state from `GET /api/auth/me/research-mode`. The
* chart gates on `enabled === true && acknowledgedVersion ===
* currentDisclaimerVersion` — defence-in-depth alongside the
* server-side 400 the POST returns for stale versions.
*
* Pure math: `computeOneCompartment(drug, doses, asOf)` from
* `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.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Activity,
AlertTriangle,
Loader2,
Syringe,
} from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import {
computeOneCompartment,
type DoseEvent,
} from "@/lib/medications/glp1-pk";
import {
findDrugIdByBrand,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMg } from "@/lib/medications/dose-string";
import type { ResearchModeStatus } from "@/lib/medications/research-mode-types";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
const CHART_COLOR = "var(--dracula-purple)";
const CHART_FILL_OPACITY = 0.18;
const HOURS_PER_DAY = 24;
/** 21 days back, 0 days forward — three weekly cycles of history with
* "now" at the right edge. Matches research §2.4's recommendation
* for a research-view chart that emphasises the sawtooth shape. */
const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY;
const WINDOW_HOURS_AFTER = 0;
const SAMPLE_STEP_HOURS = 6;
interface IntakeEvent {
id: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string;
}
interface DoseChange {
id: string;
effectiveFrom: string;
doseValue: number;
doseUnit: string;
}
interface Glp1DetailsResponse {
doseChanges: DoseChange[];
}
export interface DrugLevelChartProps {
/**
* Medication row (already known to be GLP-1 by the parent — typically
* because `treatmentClass === "GLP1"`). The component resolves the
* Glp1DrugId via the brand catalog; if the brand isn't recognised
* the chart renders an empty-state hint instead of throwing.
*/
medication: {
id: string;
/** Human-readable brand or generic name (e.g. "Mounjaro"). */
name: string;
/** Headline dose string ("7.5 mg") used as a fallback when no
* per-event dose-change row covers the intake's takenAt. */
dose: string;
};
/**
* Override "now" for deterministic snapshot tests. Defaults to a
* fresh `Date()` at render time. The chart re-anchors on every
* render, but the underlying React Query data keeps the network
* cost low.
*/
asOf?: Date;
}
export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
const { t } = useTranslations();
// Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N
// hoisted `findDrugIdByBrand` so this chart and the titration route
// share one implementation.
const drugId: Glp1DrugId | null = useMemo(
() => findDrugIdByBrand(medication.name),
[medication.name],
);
const { data: researchMode, isLoading: rmLoading } =
useQuery<ResearchModeStatus | null>({
queryKey: ["research-mode"],
queryFn: async () => {
const res = await fetch("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
},
staleTime: 60 * 1000,
});
const versionsAligned =
!!researchMode &&
researchMode.acknowledgedVersion === researchMode.currentDisclaimerVersion;
const gateOpen = !!researchMode?.enabled && versionsAligned;
const { data: details, isLoading: detailsLoading } =
useQuery<Glp1DetailsResponse | null>({
queryKey: ["medications", medication.id, "glp1-details"],
queryFn: async () => {
const res = await fetch(`/api/medications/${medication.id}/glp1`);
if (!res.ok) return null;
const json = await res.json();
return json.data as Glp1DetailsResponse;
},
// Only fetch when the user has opted in; saves a round-trip for
// anyone who hasn't enabled Research Mode.
enabled: gateOpen && !!drugId,
staleTime: 60 * 1000,
});
const { data: intakeEnvelope, isLoading: intakeLoading } = useQuery({
queryKey: ["medications", medication.id, "intake", "drug-level-chart"],
queryFn: async () => {
const res = await fetch(
`/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`,
);
if (!res.ok) return null;
const json = await res.json();
return json.data as { events: IntakeEvent[] };
},
enabled: gateOpen && !!drugId,
staleTime: 60 * 1000,
});
// Resolve "now" once per render — the asOf override is the only
// safe way to pin the curve for snapshot tests.
const now = useMemo(() => asOf ?? new Date(), [asOf]);
const doses = useMemo<DoseEvent[]>(() => {
if (!drugId || !intakeEnvelope?.events) return [];
const dchg = details?.doseChanges ?? [];
const fallbackMg = parseDoseMg(medication.dose);
return intakeEnvelope.events
.filter((ev) => !ev.skipped && ev.takenAt)
.map((ev) => ({
takenAt: new Date(ev.takenAt as string),
doseMg: resolveDoseMg(ev.takenAt as string, dchg, fallbackMg),
}))
.filter((d) => Number.isFinite(d.doseMg) && d.doseMg > 0);
}, [drugId, intakeEnvelope, details, medication.dose]);
// ── Section header always renders so the parent's "Estimated drug
// level" anchor stays stable; the body switches between gated /
// loading / empty / chart states.
const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading));
const hasDoses = doses.length > 0;
const animationsEnabled = !prefersReducedMotion();
return (
<section
aria-labelledby="drug-level-chart-title"
className="bg-card border-border rounded-xl border p-4 md:p-6"
data-slot="drug-level-chart"
>
<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>
{/* 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="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")}
/>
)}
{gateOpen && hasDoses && (
<p
className="text-muted-foreground mt-2 text-xs italic"
data-slot="drug-level-chart-disclaimer"
>
{t("medications.researchMode.chart.estimateNote")}
</p>
)}
</section>
);
}
/* ────────────────────────────────────────────────────────────────
* Body sub-components — pulled out so the tests can pin the gated
* branches without rendering the Recharts tree (Recharts in SSR is
* already exercised by the dashboard chart tests).
* ──────────────────────────────────────────────────────────────── */
function GatedUnknownDrug({ medicationName }: { medicationName: string }) {
const { t } = useTranslations();
return (
<div
className="text-muted-foreground bg-muted/40 rounded-md p-4 text-sm"
data-slot="drug-level-chart-unknown-drug"
>
<p>
{t("medications.researchMode.chart.unknownDrug", {
name: medicationName,
})}
</p>
</div>
);
}
function GatedPlaceholder({
versionsAligned,
knownState,
}: {
versionsAligned: boolean;
knownState: ResearchModeStatus | null | undefined;
}) {
const { t } = useTranslations();
// Three distinguishable states:
// - Research Mode is off entirely.
// - Research Mode is on but the acknowledged version is stale.
// - We don't know yet (server returned null) — still render the
// opt-in CTA so the user has a path forward.
const stale = !!knownState?.enabled && !versionsAligned;
return (
<div
className="bg-muted/40 border-border space-y-3 rounded-md border p-4 text-sm"
data-slot="drug-level-chart-gated"
data-stale={stale ? "true" : "false"}
>
<div className="flex items-start gap-2">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-1">
<p className="text-foreground font-medium">
{stale
? t("medications.researchMode.chart.gatedStaleTitle")
: t("medications.researchMode.chart.gatedTitle")}
</p>
<p className="text-muted-foreground">
{stale
? t("medications.researchMode.chart.gatedStaleBody")
: t("medications.researchMode.chart.gatedBody")}
</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("medications.researchMode.chart.gatedCta")}
</Link>
</Button>
</div>
);
}
function EmptyState() {
const { t } = useTranslations();
return (
<div
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
data-slot="drug-level-chart-empty"
>
<div className="flex items-center gap-2">
<Syringe className="h-4 w-4 shrink-0" aria-hidden="true" />
<p>{t("medications.researchMode.chart.emptyState")}</p>
</div>
<p className="text-xs italic">
{t("medications.researchMode.chart.emptyStateCta")}
</p>
</div>
);
}
function ChartBody({
drug,
doses,
asOf,
animationsEnabled,
axisLabel,
}: {
drug: Glp1DrugId;
doses: readonly DoseEvent[];
asOf: Date;
animationsEnabled: boolean;
/**
* Y-axis caption — research §2.3 requires "Estimated level (relative)"
* with NO unit. Rendered both as the Recharts axis label (in the SVG)
* and as a small visible caption above the chart so screen-reader
* users and SSR snapshots both surface the framing.
*/
axisLabel: string;
}) {
const samples = useMemo(
() =>
computeOneCompartment(drug, doses, asOf, {
windowHoursBefore: WINDOW_HOURS_BEFORE,
windowHoursAfter: WINDOW_HOURS_AFTER,
stepHours: SAMPLE_STEP_HOURS,
}),
[drug, doses, asOf],
);
// Map samples to chart points. x-axis is "days since now" — negative
// values for the past 21 days, with 0 = now. Recharts uses the raw
// domain so categorical day labels don't crowd a 21-day window.
const chartData = useMemo(
() =>
samples.map((s) => ({
dayOffset: Math.round((s.tHours / HOURS_PER_DAY) * 10) / 10,
level: s.concentration,
})),
[samples],
);
return (
<>
<p
className="text-muted-foreground mb-1 text-xs"
data-slot="drug-level-chart-axis-caption"
>
{axisLabel}
</p>
<div
className="h-[240px] touch-pan-y"
data-slot="drug-level-chart-area"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 12, bottom: 16, left: 8 }}
>
<defs>
<linearGradient
id="drug-level-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={CHART_COLOR}
stopOpacity={CHART_FILL_OPACITY * 1.4}
/>
<stop
offset="100%"
stopColor={CHART_COLOR}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="dayOffset"
type="number"
domain={[-21, 0]}
ticks={[-21, -14, -7, 0]}
tickFormatter={(v) => (v === 0 ? "0" : `${v}d`)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
>
<text
x="50%"
y="100%"
dy={14}
textAnchor="middle"
fontSize={10}
fill="var(--muted-foreground)"
/>
</XAxis>
{/* Research §2.3 — y-axis is unit-less. We hide tick labels
and the axis line entirely; the human-readable label sits
outside the chart frame, above. The `tick={false}` flag
suppresses the textual ticks per Recharts. */}
<YAxis
domain={[0, "auto"]}
tick={false}
tickLine={false}
axisLine={false}
width={1}
label={{
value: axisLabel,
angle: -90,
position: "insideLeft",
fontSize: 10,
fill: "var(--muted-foreground)",
offset: 0,
}}
/>
<Tooltip
content={() => null}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
dataKey="level"
stroke={CHART_COLOR}
strokeWidth={2}
fill="url(#drug-level-gradient)"
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</>
);
}
/* ────────────────────────────────────────────────────────────────
* Pure helpers — dose-mg resolution from the medication's dose
* string and the dose-change timeline.
* ──────────────────────────────────────────────────────────────── */
/**
* Re-export the shared parser so the component-test file
* (which historically imported `parseDoseMg` from this surface)
* keeps working without coupling to the helper module path. v1.4.25
* W21 Fix-N — single implementation now lives in
* `src/lib/medications/dose-string.ts`.
*/
export { parseDoseMg };
/**
* Resolve the dose in mg that applied at `takenAt` by walking the
* dose-change history. The catalog row with the latest
* `effectiveFrom ≤ takenAt` wins; intakes before any row fall back
* to the medication's headline `dose` string.
*
* Exported for the unit test.
*/
export function resolveDoseMg(
takenAtIso: string,
doseChanges: readonly DoseChange[],
fallbackMg: number,
): number {
const takenAtMs = Date.parse(takenAtIso);
if (!Number.isFinite(takenAtMs)) return fallbackMg;
let bestValue = fallbackMg;
let bestStart = -Infinity;
for (const change of doseChanges) {
const startMs = Date.parse(change.effectiveFrom);
if (!Number.isFinite(startMs)) continue;
if (startMs <= takenAtMs && startMs > bestStart) {
bestStart = startMs;
bestValue = change.doseValue;
}
}
return bestValue;
}
@@ -0,0 +1,280 @@
"use client";
/**
* v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog.
*
* Opt-in gate in front of the GLP-1 drug-level chart. The chart paints
* a unit-less Bateman PK curve over the user's logged intakes (research
* §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU
* MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4),
* the chart never paints until the user has explicitly read and
* acknowledged the disclaimer below. The acknowledgment record is
* server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy
* changes the user re-acknowledges before the chart unhides.
*
* The dialog is rendered controlled by the Settings toggle and the
* "re-prompt" banner (commit 3 of this phase). It accepts:
* - `open` / `onOpenChange` for full external control
* - `currentDisclaimerVersion` — the server's authoritative version
* string. Acknowledgment posts THIS value, not the imported
* constant — server redeploys that bump the constant must be
* reflected without a client reload (W19c-Backend phase report).
* - `onAcknowledged()` — fires after a successful 200; the caller
* re-fetches the research-mode state and renders the toggle in
* the new state.
*
* Copy direction (Marc-Voice, English, no AI/phase/wave mentions):
* - "What this is" — single-paragraph framing: the chart is an
* estimate from EMA-published population PK.
* - "What this isn't" — explicit boundary: not a measurement, not
* personalised, not medical advice.
* - "Why it's an estimate" — population PK + IIV; per-patient
* variation is large.
* - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim.
* - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099).
*
* Toast surface uses sonner (the project convention) so the user gets
* a transient confirmation on either success or rejection.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
export interface ResearchModeAcknowledgmentDialogProps {
open: boolean;
onOpenChange: (next: boolean) => void;
/**
* The version string from `GET /api/auth/me/research-mode`. The
* server compares it byte-for-byte against the current constant; a
* stale value forces a 400 from the API which we surface as an
* error toast and keep the dialog open. Pass `null` while the
* GET is in flight; the Acknowledge CTA stays disabled.
*/
currentDisclaimerVersion: string | null;
/**
* Fires after the POST returns 200. Caller invalidates the
* `research-mode` query so the toggle reflects the new state.
*/
onAcknowledged?: () => void;
}
export function ResearchModeAcknowledgmentDialog({
open,
onOpenChange,
currentDisclaimerVersion,
onAcknowledged,
}: ResearchModeAcknowledgmentDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async (version: string) => {
const res = await fetch("/api/auth/me/research-mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ acknowledged: true, version }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const code =
typeof json?.error === "string"
? json.error
: `http-${res.status}`;
throw new Error(code);
}
return json?.data ?? null;
},
onSuccess: () => {
setErrorMessage(null);
toast.success(t("medications.researchMode.dialog.successToast"));
queryClient.invalidateQueries({ queryKey: ["research-mode"] });
onAcknowledged?.();
onOpenChange(false);
},
onError: (err: unknown) => {
// The 400 stale-version path is the only one we surface
// explicitly — every other failure mode (401, 429, 500, network)
// collapses to a generic error so the dialog stays open but the
// user knows the acknowledgment didn't persist.
const code = err instanceof Error ? err.message : "unknown";
const isStale = code === "research-mode.version.stale";
const message = isStale
? t("medications.researchMode.dialog.staleVersionToast")
: t("medications.researchMode.dialog.errorToast");
setErrorMessage(message);
toast.error(message);
},
});
function handleAcknowledge() {
if (!currentDisclaimerVersion) return;
mutation.mutate(currentDisclaimerVersion);
}
function handleCancel() {
setErrorMessage(null);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="research-mode-acknowledgment-dialog"
className="flex max-h-[90vh] flex-col sm:max-w-xl"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</DialogTitle>
<DialogDescription>
{t("medications.researchMode.dialog.intro")}
</DialogDescription>
</DialogHeader>
{/* Only the body scrolls; header + footer stay pinned so the
Acknowledge / Cancel CTAs are always reachable without
scrolling on small viewports (the previous markup scrolled
the entire DialogContent, pushing the footer below the
fold on iPhone-class heights). */}
<div className="-mr-2 flex-1 space-y-4 overflow-y-auto pr-2 text-sm">
<section
aria-labelledby="research-mode-what-it-is"
data-slot="research-mode-what-it-is"
>
<h3
id="research-mode-what-it-is"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whatItIsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIs")}
</p>
</section>
<section
aria-labelledby="research-mode-what-it-isnt"
data-slot="research-mode-what-it-isnt"
>
<h3
id="research-mode-what-it-isnt"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whatItIsntHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIsnt")}
</p>
</section>
<section
aria-labelledby="research-mode-why-estimate"
data-slot="research-mode-why-estimate"
>
<h3
id="research-mode-why-estimate"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whyEstimateHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whyEstimate")}
</p>
</section>
<section
aria-labelledby="research-mode-mdr"
data-slot="research-mode-mdr-boundary"
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
>
<h3
id="research-mode-mdr"
className="text-foreground flex items-center gap-2 font-medium"
>
<AlertTriangle
className="text-warning h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
</h3>
<p className="text-foreground/85 mt-1">
{t("medications.researchMode.dialog.mdrBoundary")}
</p>
</section>
<section
aria-labelledby="research-mode-citations"
data-slot="research-mode-citations"
>
<h3
id="research-mode-citations"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.citationsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.citations")}
</p>
</section>
{currentDisclaimerVersion && (
<p
className="text-muted-foreground text-xs italic"
data-slot="research-mode-version"
>
{t("medications.researchMode.dialog.versionLine", {
version: currentDisclaimerVersion,
})}
</p>
)}
{errorMessage && (
<p
role="alert"
className="text-destructive text-sm"
data-slot="research-mode-error"
>
{errorMessage}
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={mutation.isPending}
data-slot="research-mode-cancel"
>
{t("medications.researchMode.dialog.cancelCta")}
</Button>
<Button
onClick={handleAcknowledge}
disabled={mutation.isPending || !currentDisclaimerVersion}
data-slot="research-mode-acknowledge"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t("medications.researchMode.dialog.acknowledgeCta")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Loader2, BellRing, BellOff, Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
/**
* 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.
*
* Three sub-sections, top-to-bottom:
*
* 1. Header strip — reminder on/off badge + next-due chip + "Edit
* schedule" link back to the medication list (where the existing
* `medication-form` is the canonical schedule editor; we
* deliberately do not re-implement the schedule editor here so
* there's no second source of truth).
*
* 2. Cadence visualisation — a 30-day track showing one cell per
* expected dose, status-coloured (taken / skipped / missed /
* upcoming). The legend below the track names each status.
* v1.4.25 W21 Fix-N — each cell is wrapped in a 44×44 px button
* to meet the WCAG 2.5.5 / Apple HIG tap-target rule; the
* visible cell remains the 12 px square the design system pins.
*
* 3. Compliance chips — four monochrome chips: adherence rate %,
* current streak, longest streak, missed last 30. Marc-memory:
* no gamified badges, no WebP art.
*
* Data comes from /api/medications/[id]/cadence; the route delegates
* to the pure scheduling helpers so the chart, the chips, and the
* route agree dose-for-dose.
*/
interface CadenceTimelineEntry {
day: string;
windowStart: string;
windowEnd: string;
scheduleIndex: number;
status: "taken" | "skipped" | "missed" | "upcoming";
}
interface CadenceChips {
adherenceRate: number | null;
currentStreak: number;
longestStreak: number;
missedLast30: number;
windowDays: number;
}
interface CadenceResponse {
windowDays: number;
anchorIso: string;
next: {
windowStart: string;
windowEnd: string;
scheduleIndex: number;
} | null;
chips: CadenceChips;
timeline: CadenceTimelineEntry[];
}
interface SchedulingSectionProps {
medicationId: string;
reminderEnabled: boolean;
}
const STATUS_STYLES: Record<CadenceTimelineEntry["status"], string> = {
// Monochrome scale; chart visually identical contract — no new colour
// tokens, all classes already exist in the design system.
taken: "bg-primary/85",
skipped: "bg-muted",
missed: "bg-destructive/65",
upcoming: "border-border/70 border bg-background",
};
export function SchedulingSection({
medicationId,
reminderEnabled,
}: SchedulingSectionProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, error } = useQuery({
queryKey: ["medications", medicationId, "cadence"],
queryFn: async (): Promise<CadenceResponse> => {
const res = await fetch(
`/api/medications/${medicationId}/cadence?days=30`,
);
if (!res.ok) {
throw new Error(`Failed to load cadence: ${res.status}`);
}
const json = await res.json();
return json.data as CadenceResponse;
},
});
const adherenceLabel = useMemo(() => {
if (!data) return null;
if (data.chips.adherenceRate === null) {
return t("medications.scheduling.compliance.noData");
}
return `${data.chips.adherenceRate}%`;
}, [data, t]);
const headerExtras = (
<div className="flex items-center gap-2">
<Badge
variant={reminderEnabled ? "secondary" : "outline"}
className="gap-1 text-[10px]"
>
{reminderEnabled ? (
<BellRing className="h-3 w-3" />
) : (
<BellOff className="h-3 w-3" />
)}
{t(
reminderEnabled
? "medications.scheduling.reminders.on"
: "medications.scheduling.reminders.off",
)}
</Badge>
<Button
size="sm"
variant="outline"
asChild
aria-label={t("medications.scheduling.editCta")}
>
<Link href="/medications">
<Pencil className="mr-1.5 h-3.5 w-3.5" />
{t("medications.scheduling.editCta")}
</Link>
</Button>
</div>
);
return (
<MedicationDetailSection
titleId="scheduling-heading"
title={t("medications.scheduling.section")}
headerExtras={headerExtras}
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.scheduling.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.scheduling.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-4">
{/* Next-dose chip */}
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground">
{data.next ? (
<>
{t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "}
<span className="text-foreground font-medium">
{fmt.dateShort(new Date(data.next.windowStart))}
</span>
</>
) : (
t("medications.scheduling.cadenceViz.noNextDose")
)}
</p>
</div>
{/* 30-day timeline */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.cadenceViz.title")}
</p>
{data.timeline.length === 0 ? (
<p className="text-muted-foreground py-1">
{t("medications.scheduling.cadenceViz.emptyState")}
</p>
) : (
<>
<div
className="flex flex-wrap gap-1"
role="img"
aria-label={t(
"medications.scheduling.cadenceViz.ariaTimeline",
)}
data-slot="cadence-timeline"
>
{data.timeline.map((slot, i) => {
const slotLabel = `${fmt.dateShort(new Date(slot.windowStart))} — ${t(
`medications.scheduling.cadenceViz.status.${slot.status}`,
)}`;
return (
<button
type="button"
key={`${slot.windowStart}-${i}`}
title={slotLabel}
aria-label={slotLabel}
data-slot="cadence-timeline-cell"
data-status={slot.status}
// 44×44 tap target per WCAG 2.5.5; the visible
// cell stays the 12 px square the design system
// pins inside, centered with flex.
className="inline-flex h-11 w-11 items-center justify-center rounded-sm focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2"
>
<span
className={`h-3 w-3 rounded-sm ${STATUS_STYLES[slot.status]}`}
aria-hidden="true"
/>
</button>
);
})}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-[10px]">
{(["taken", "skipped", "missed", "upcoming"] as const).map(
(s) => (
<span key={s} className="inline-flex items-center gap-1">
<span
className={`h-2.5 w-2.5 rounded-sm ${STATUS_STYLES[s]}`}
/>
<span>
{t(
`medications.scheduling.cadenceViz.status.${s}`,
)}
</span>
</span>
),
)}
</div>
</>
)}
</div>
{/* Compliance chips */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.compliance.title")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<ComplianceChip
label={t("medications.scheduling.compliance.adherenceRate")}
value={adherenceLabel ?? "—"}
tooltip={t(
"medications.scheduling.compliance.chips.adherenceTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.currentStreak")}
value={`${data.chips.currentStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.streakTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.longestStreak")}
value={`${data.chips.longestStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.longestTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.missedLast30")}
value={`${data.chips.missedLast30} ${t(
"medications.scheduling.compliance.unit.doses",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.missedTooltip",
)}
/>
</div>
</div>
</div>
)}
</MedicationDetailSection>
);
}
interface ComplianceChipProps {
label: string;
value: string;
tooltip: string;
}
function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) {
return (
<div
className="bg-muted/30 rounded-md px-2.5 py-1.5"
title={tooltip}
aria-label={`${label}: ${value}. ${tooltip}`}
>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
{label}
</p>
<p className="text-foreground text-sm font-medium">{value}</p>
</div>
);
}
@@ -0,0 +1,486 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
MedicationSideEffectCategory,
MedicationSideEffectEntry,
} from "@/generated/prisma/client";
import {
SIDE_EFFECT_CATEGORY_ORDER,
SIDE_EFFECT_SEVERITY_LADDER,
categoryForEntry,
entriesByCategory,
severityLikertLabel,
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
/**
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
* page. Sits below the drug-level chart and above the intake history
* timeline. Mounted only for `treatmentClass === "GLP1"` rows.
*
* UX shape:
* - Section header + "Log side effect" CTA opens a modal dialog.
* - Inside the dialog: category picker → entry chip-picker (filtered
* by category) → 5-Likert severity ladder → optional notes.
* - Below the header: timeline of the last 30 days, newest first,
* each row showing category badge + entry label + severity chip +
* 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
* native `<select>` for the shadcn-radix `<Select>` so the picker
* matches the rest of the form library (focus rings, keyboard nav,
* theme tokens).
*
* Component-tests cover: empty state, category → entry filtering,
* submit-and-refetch flow, severity-label translation lookup.
*/
interface SideEffectRow {
id: string;
category: MedicationSideEffectCategory;
entry: MedicationSideEffectEntry;
severity: number;
occurredAt: string;
notes: string | null;
createdAt: string;
}
interface SideEffectsSectionProps {
medicationId: string;
}
const NOTES_MAX = 280;
function categoryI18nKey(category: MedicationSideEffectCategory): string {
// GI → gi, METABOLIC → metabolic, INJECTION_SITE → injectionSite,
// COGNITIVE → cognitive, GLP1_SPECIFIC → glp1Specific.
switch (category) {
case "GI":
return "gi";
case "METABOLIC":
return "metabolic";
case "INJECTION_SITE":
return "injectionSite";
case "COGNITIVE":
return "cognitive";
case "GLP1_SPECIFIC":
return "glp1Specific";
}
}
function entryI18nKey(entry: MedicationSideEffectEntry): string {
// SCREAMING_SNAKE → camelCase.
return entry
.toLowerCase()
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [category, setCategory] = useState<MedicationSideEffectCategory>("GI");
const [entry, setEntry] = useState<MedicationSideEffectEntry | null>(null);
const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null);
const [notes, setNotes] = useState<string>("");
const [formError, setFormError] = useState<string | null>(null);
const listKey = [
"medications",
medicationId,
"side-effects",
"list",
] as const;
const { data, isLoading } = useQuery({
queryKey: listKey,
queryFn: async () => {
// Last 30 days, newest-first.
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`;
const res = await fetch(url);
if (!res.ok) return { items: [] as SideEffectRow[], meta: { total: 0 } };
const json = (await res.json()) as {
data: { items: SideEffectRow[]; meta: { total: number } };
};
return json.data;
},
staleTime: 30 * 1000,
});
const createMutation = useMutation({
mutationFn: async (payload: {
entry: MedicationSideEffectEntry;
severity: number;
notes: string | null;
}) => {
// v1.4.25 W21 Fix-N (code-M6) — drop client-side `category`
// from the wire payload. The server derives it from the entry
// via `categoryForEntry`. Backwards-compatible with older
// clients during the cut window (route accepts both shapes).
const res = await fetch(
`/api/medications/${medicationId}/side-effects`,
{
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 log side effect");
}
return (await res.json()).data as SideEffectRow;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const deleteMutation = useMutation({
mutationFn: async (logId: string) => {
const res = await fetch(
`/api/medications/${medicationId}/side-effects/${logId}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Failed to delete entry");
return true;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const items = data?.items ?? [];
const filteredEntries = useMemo(
() => entriesByCategory(category),
[category],
);
function resetForm() {
setCategory("GI");
setEntry(null);
setSeverity(null);
setNotes("");
setFormError(null);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setFormError(null);
if (!entry) {
setFormError(t("medications.sideEffects.errorEntry"));
return;
}
if (!severity) {
setFormError(t("medications.sideEffects.errorSeverity"));
return;
}
// Sanity-check the entry still belongs to the picked category;
// the server derives the category authoritatively from `entry`.
void categoryForEntry(entry);
createMutation.mutate(
{
entry,
severity,
notes: notes.trim() ? notes.trim().slice(0, NOTES_MAX) : null,
},
{
onSuccess: () => {
resetForm();
setOpen(false);
},
onError: (err) => {
setFormError(err instanceof Error ? err.message : String(err));
},
},
);
}
const headerExtras = (
<>
{items.length > 0 && (
<span className="text-muted-foreground mr-2 font-normal">
({items.length})
</span>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
</>
);
return (
<MedicationDetailSection
titleId="side-effects-heading"
title={t("medications.sideEffects.section")}
headerExtras={headerExtras}
bodyPaddingY="py-2.5"
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.sideEffects.loading")}</span>
</div>
)}
{!isLoading && items.length === 0 && (
<p className="text-muted-foreground py-1">
{t("medications.sideEffects.emptyState")}
</p>
)}
{!isLoading && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.sideEffects.recentTitle")}
</p>
<ul className="space-y-1.5">
{items.map((row) => (
<li
key={row.id}
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>
</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">
{row.notes}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => {
if (
typeof window === "undefined" ||
window.confirm(
t("medications.sideEffects.deleteConfirm"),
)
) {
deleteMutation.mutate(row.id);
}
}}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</div>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.sideEffects.sheetTitle")}</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit}
className="space-y-4"
aria-label={t("medications.sideEffects.sheetTitle")}
>
<div className="space-y-1.5">
<Label htmlFor="side-effect-category">
{t("medications.sideEffects.categoryLabel")}
</Label>
<Select
value={category}
onValueChange={(next) => {
setCategory(next as MedicationSideEffectCategory);
// Reset entry — the previously-selected entry probably
// doesn't belong to the new category.
setEntry(null);
}}
>
<SelectTrigger
id="side-effect-category"
className="w-full"
data-slot="side-effect-category-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
<SelectItem key={c} value={c}>
{t(
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.entryLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.entryLabel")}
>
{filteredEntries.map((candidate) => {
const selected = candidate === entry;
return (
<button
type="button"
key={candidate}
role="radio"
aria-checked={selected}
onClick={() => setEntry(candidate)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
{t(
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
)}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.severityLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.severityLabel")}
>
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
const selected = severity === value;
return (
<button
type="button"
key={label}
role="radio"
aria-checked={selected}
onClick={() => setSeverity(value)}
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
<span aria-hidden className="block text-sm">
{value}
</span>
<span className="block text-[10px] opacity-80">
{t(`medications.sideEffects.severity.${label}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="side-effect-notes">
{t("medications.sideEffects.notesLabel")}
</Label>
<textarea
id="side-effect-notes"
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
placeholder={t("medications.sideEffects.notesPlaceholder")}
rows={3}
maxLength={NOTES_MAX}
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
/>
<p className="text-muted-foreground text-right text-[10px]">
{notes.length} / {NOTES_MAX}
</p>
</div>
{formError && (
<p className="text-destructive text-sm" role="alert">
{formError}
</p>
)}
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
{t("medications.sideEffects.cancelCta")}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("medications.sideEffects.submitCta")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</MedicationDetailSection>
);
}
@@ -0,0 +1,235 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
/**
* 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.
*
* Layout:
* - Header: drug INN + "Standard ladder (EMA reference)"
* - Vertical step list (mobile) — same nodes laid horizontally
* from `sm` upwards. Each step shows dose, typical-weeks badge,
* and a status dot (current / past / upcoming).
* - Current-step ring with "You are here" caption.
* - Next-step caption + observational dwell hint when the EMA
* dwell-time has elapsed. Copy is strictly observational; the
* ladder is a *reference*, never a prescription (MDR boundary).
* - Disclaimer + EMA-source link.
*
* v1.4.25 W21 Fix-N — dropped the hand-rolled `templateFill` helper;
* the i18n `t()` function already accepts the `params` bag with
* `{key}` placeholder substitution. One source of truth for the
* template-fill semantics.
*
* Data comes from `/api/medications/[id]/titration`; that route owns
* the prisma queries and delegates math to the pure ladder helpers.
*/
interface TitrationStep {
stepIndex: number;
doseMg: number;
typicalWeeks: number;
}
interface TitrationResponse {
drugId: string;
drugInn: string;
ladder: TitrationStep[];
currentStep: TitrationStep | null;
currentStepIndex: number | null;
weeksOnCurrentStep: number;
nextStep: TitrationStep | null;
escalationDue: boolean;
sourceEMA: string;
}
interface TitrationSectionProps {
medicationId: string;
}
export function TitrationSection({ medicationId }: TitrationSectionProps) {
const { t } = useTranslations();
const { data, isLoading, error } = useQuery({
queryKey: ["medications", medicationId, "titration"],
queryFn: async (): Promise<TitrationResponse> => {
const res = await fetch(`/api/medications/${medicationId}/titration`);
if (!res.ok) {
throw new Error(`Failed to load titration: ${res.status}`);
}
const json = await res.json();
return json.data as TitrationResponse;
},
});
const hasLadder = !!data && data.ladder.length > 0;
const currentIdx = data?.currentStepIndex ?? null;
// Compose the optional dwell hint AND the next-step caption. The
// dwell hint is strictly observational — no "you should step up";
// the boolean only gates *display*, never *action*.
const dwellHint = useMemo(() => {
if (!data || data.currentStep === null) return null;
if (data.weeksOnCurrentStep <= 0) return null;
if (data.escalationDue) {
return t("medications.titration.escalationDueHint", {
weeks: data.weeksOnCurrentStep,
typical: data.currentStep.typicalWeeks,
});
}
return t("medications.titration.currentStepDwell", {
weeks: data.weeksOnCurrentStep,
});
}, [data, t]);
const nextStepCaption = useMemo(() => {
if (!data) return null;
if (!data.nextStep) return null;
return t("medications.titration.nextStepCaption", {
dose: data.nextStep.doseMg,
weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks,
});
}, [data, t]);
const headerExtras = data ? (
<span
className="text-muted-foreground text-[10px] uppercase tracking-wide"
data-slot="titration-drug-inn"
>
{data.drugInn}
</span>
) : null;
return (
<MedicationDetailSection
titleId="titration-heading"
title={t("medications.titration.section")}
headerExtras={headerExtras}
dataSlot="titration-section"
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.titration.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.titration.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-3">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.titration.drugLadderHeader")}
</p>
{hasLadder ? (
<ol
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-stretch"
aria-label={t("medications.titration.drugLadderHeader")}
>
{data.ladder.map((step) => {
const isCurrent = currentIdx === step.stepIndex;
const isPast =
currentIdx !== null && step.stepIndex < currentIdx;
return (
<li
key={step.stepIndex}
className={[
"border-border/70 flex flex-1 flex-col rounded-md border px-2.5 py-2 sm:min-w-[7rem]",
isCurrent
? "border-primary ring-primary/40 ring-2"
: "",
].join(" ")}
aria-current={isCurrent ? "step" : undefined}
data-slot="titration-step"
data-step-index={step.stepIndex}
data-step-state={
isCurrent ? "current" : isPast ? "past" : "upcoming"
}
>
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{t("medications.titration.stepLabel", {
n: step.stepIndex + 1,
})}
</span>
<span className="text-foreground text-sm font-medium">
{step.doseMg} {t("medications.titration.doseUnitMg")}
</span>
<span className="text-muted-foreground text-[10px]">
{t("medications.titration.typicalWeeksOnStep", {
weeks: step.typicalWeeks,
})}
</span>
{isCurrent && (
<span className="text-primary mt-1 text-[10px] font-medium uppercase tracking-wide">
{t("medications.titration.youAreHere")}
</span>
)}
</li>
);
})}
</ol>
) : (
<p className="text-muted-foreground">
{t("medications.titration.emptyState")}
</p>
)}
{data.currentStep === null && hasLadder && (
<p className="text-muted-foreground">
{t("medications.titration.nonStandardDose")}
</p>
)}
{dwellHint && (
<p
className="text-muted-foreground"
data-slot="titration-dwell-hint"
>
{dwellHint}
</p>
)}
{nextStepCaption && (
<p className="text-muted-foreground">{nextStepCaption}</p>
)}
{data.nextStep === null && data.currentStep !== null && (
<p className="text-muted-foreground">
{t("medications.titration.ceilingMessage")}
</p>
)}
<div className="border-border/60 border-t pt-2">
<p className="text-muted-foreground text-[10px]">
{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"
>
{t("medications.titration.emaSourceCta")}
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
</a>
</div>
</div>
)}
</MedicationDetailSection>
);
}
@@ -0,0 +1,339 @@
/**
* v1.4.25 W19c-Frontend — Drug-level chart tests.
*
* Pins the gating decision tree (research §2.3 + §12.4 + W19c-Backend
* phase report). The chart MUST hide until:
* - the medication's brand resolves to a Glp1DrugId, AND
* - `enabled === true` AND
* - `acknowledgedVersion === currentDisclaimerVersion`.
*
* Each gate failure renders a distinct placeholder so the SSR markup
* carries an unambiguous data-slot for assertion. Recharts in
* `renderToStaticMarkup` only paints the static frame (the
* `<ResponsiveContainer>` mounts but doesn't measure), so we assert on
* `data-slot="drug-level-chart-area"` presence + the gradient
* definition + the y-axis having no `<text>` tick labels.
*
* Pure helpers (`parseDoseMg`, `resolveDoseMg`) get their own unit
* coverage — they're tested directly so the data-shape transformation
* stays correct without a Recharts mount.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// Capture queryKey routing so different tests can stub different
// query results. The component reads three queries:
// - ["research-mode"]
// - ["medications", id, "glp1-details"]
// - ["medications", id, "intake", "drug-level-chart"]
const queryResults: Record<string, unknown> = {};
function setQueryResult(keyJoined: string, data: unknown) {
queryResults[keyJoined] = data;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = (queryKey as Array<string | number>).join("/");
return {
data: queryResults[key],
isLoading: false,
};
},
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
// Pin the PK compute helper so we can assert "the chart asked for the
// right doses" without re-running the math.
const pkCalls: Array<{
drug: string;
doses: Array<{ takenAt: Date; doseMg: number }>;
asOf: Date;
}> = [];
vi.mock("@/lib/medications/glp1-pk", async () => {
const actual =
await vi.importActual<typeof import("@/lib/medications/glp1-pk")>(
"@/lib/medications/glp1-pk",
);
return {
...actual,
computeOneCompartment: (
drug: string,
doses: Array<{ takenAt: Date; doseMg: number }>,
asOf: Date,
) => {
pkCalls.push({ drug, doses, asOf });
// Return a small fixed series so the chart renders a non-empty
// SVG path. Two samples is enough to verify the Area renders.
return [
{ tHours: -24 * 21, concentration: 0 },
{ tHours: -24 * 14, concentration: 0.6 },
{ tHours: -24 * 7, concentration: 1.0 },
{ tHours: 0, concentration: 0.7 },
];
},
};
});
import {
DrugLevelChart,
parseDoseMg,
resolveDoseMg,
} from "../DrugLevelChart";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const mounjaro = {
id: "med-1",
name: "Mounjaro",
dose: "7.5 mg",
};
const ramipril = {
id: "med-2",
name: "Ramipril",
dose: "5 mg",
};
beforeEach(() => {
for (const k of Object.keys(queryResults)) delete queryResults[k];
pkCalls.length = 0;
});
describe("<DrugLevelChart> — gating decision tree", () => {
it("renders the unknown-drug placeholder for a non-GLP-1 brand", () => {
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={ramipril} />);
expect(html).toContain('data-slot="drug-level-chart-unknown-drug"');
expect(html).toContain("Ramipril");
// Chart body must not paint when the drug is unknown.
expect(html).not.toContain('data-slot="drug-level-chart-area"');
// The PK helper is never asked to compute for an unknown drug.
expect(pkCalls).toHaveLength(0);
});
it("renders the gated placeholder when Research Mode is OFF", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart-gated"');
expect(html).toContain('data-stale="false"');
expect(html).toContain("Research Mode is off");
expect(html).toContain("Open Research Mode");
expect(html).not.toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(0);
});
it("renders the stale-disclaimer placeholder when versions differ", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-04-01T00:00:00Z",
acknowledgedVersion: "2026-04-01.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart-gated"');
expect(html).toContain('data-stale="true"');
expect(html).toContain("re-acknowledgment");
expect(html).not.toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(0);
});
it("renders the empty state when gate is open but no intake events exist", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [],
});
const html = render(<DrugLevelChart medication={mounjaro} />);
expect(html).toContain('data-slot="drug-level-chart-empty"');
expect(html).toContain("No intake events yet");
expect(html).not.toContain('data-slot="drug-level-chart-area"');
expect(pkCalls).toHaveLength(0);
});
it("renders the chart when gate is open AND intake events exist", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
setQueryResult("medications/med-1/glp1-details", {
doseChanges: [
{
id: "dc-1",
effectiveFrom: "2026-04-01T00:00:00Z",
doseValue: 7.5,
doseUnit: "mg",
},
],
});
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [
{
id: "ev-1",
takenAt: "2026-05-13T08:00:00Z",
skipped: false,
scheduledFor: "2026-05-13T08:00:00Z",
},
{
id: "ev-2",
takenAt: "2026-05-06T08:00:00Z",
skipped: false,
scheduledFor: "2026-05-06T08:00:00Z",
},
// A skipped event must NOT contribute to the curve.
{
id: "ev-3",
takenAt: null,
skipped: true,
scheduledFor: "2026-04-29T08:00:00Z",
},
],
});
const fixedNow = new Date("2026-05-14T12:00:00Z");
const html = render(
<DrugLevelChart medication={mounjaro} asOf={fixedNow} />,
);
expect(html).toContain('data-slot="drug-level-chart-area"');
expect(html).toContain("Estimated drug level");
// The estimate disclaimer must accompany the chart body.
expect(html).toContain('data-slot="drug-level-chart-disclaimer"');
// The PK helper was asked for the right drug, with only the two
// non-skipped doses, and with the supplied `asOf`.
expect(pkCalls).toHaveLength(1);
const [call] = pkCalls;
expect(call.drug).toBe("tirzepatide");
expect(call.doses).toHaveLength(2);
expect(call.doses.every((d) => d.doseMg === 7.5)).toBe(true);
expect(call.asOf).toEqual(fixedNow);
});
it("hides the y-axis tick labels (research §2.3, unit-less)", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T00:00:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
setQueryResult("medications/med-1/intake/drug-level-chart", {
events: [
{
id: "ev-1",
takenAt: "2026-05-13T08:00:00Z",
skipped: false,
scheduledFor: "2026-05-13T08:00:00Z",
},
],
});
const html = render(
<DrugLevelChart
medication={mounjaro}
asOf={new Date("2026-05-14T12:00:00Z")}
/>,
);
// Recharts paints `<text class="recharts-cartesian-axis-tick-value">`
// for each numeric tick. The drug-level chart suppresses those for
// the Y axis by passing `tick={false}` — none should appear inside
// the y-axis container. Same content scan that the dashboard chart
// tests already use.
const yAxisTickValues = html.match(
/class="recharts-yAxis.*?recharts-cartesian-axis-tick-value/g,
);
expect(yAxisTickValues).toBeNull();
// The unit-less axis label still renders (it carries the
// educational framing).
expect(html).toContain("Estimated level (relative)");
});
});
describe("parseDoseMg / resolveDoseMg helpers", () => {
it("parses standard '7.5 mg' / '12,5 mg' / '0.25 mg' shapes", () => {
expect(parseDoseMg("7.5 mg")).toBe(7.5);
expect(parseDoseMg("12,5 mg")).toBe(12.5);
expect(parseDoseMg("0.25 mg")).toBe(0.25);
expect(parseDoseMg("15 mg")).toBe(15);
});
it("returns NaN for non-numeric strings", () => {
expect(Number.isNaN(parseDoseMg(""))).toBe(true);
expect(Number.isNaN(parseDoseMg("as needed"))).toBe(true);
});
it("resolves the dose to the latest effectiveFrom <= takenAt", () => {
const history = [
{
id: "a",
effectiveFrom: "2026-01-01T00:00:00Z",
doseValue: 2.5,
doseUnit: "mg",
},
{
id: "b",
effectiveFrom: "2026-03-01T00:00:00Z",
doseValue: 5,
doseUnit: "mg",
},
{
id: "c",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: 7.5,
doseUnit: "mg",
},
];
// Before any history → fallback.
expect(resolveDoseMg("2025-12-15T00:00:00Z", history, 1)).toBe(1);
// Inside the 2.5-mg window.
expect(resolveDoseMg("2026-02-15T00:00:00Z", history, 1)).toBe(2.5);
// On the 5-mg boundary day.
expect(resolveDoseMg("2026-03-01T00:00:00Z", history, 1)).toBe(5);
// Inside the 5-mg window.
expect(resolveDoseMg("2026-04-15T00:00:00Z", history, 1)).toBe(5);
// Inside the latest window.
expect(resolveDoseMg("2026-05-10T00:00:00Z", history, 1)).toBe(7.5);
});
it("returns the fallback when takenAt is unparseable", () => {
expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42);
});
});
@@ -0,0 +1,287 @@
/**
* v1.4.25 W19c-Frontend — Research-mode acknowledgment dialog tests.
*
* The dialog is rendered controlled by Settings (commit 3) and gates
* the GLP-1 drug-level chart (commit 2). The contract is:
*
* 1. Multi-section MDR copy renders (what-is, what-isn't, why,
* MDR boundary, citations).
* 2. Acknowledge CTA fires a `POST /api/auth/me/research-mode` with
* the exact server-supplied version string.
* 3. Cancel CTA fires `onOpenChange(false)` without an API call.
* 4. The stale-version error (server 400 with
* `research-mode.version.stale`) surfaces a localised error toast
* and keeps the dialog open.
*
* Setup mirrors the existing `coach-settings-sheet.test.tsx` pattern:
* SSR via `renderToStaticMarkup` so the Radix Dialog primitives are
* mocked to plain wrappers (Portals don't materialise under SSR), and
* the TanStack Query mutation is captured so we can drive the
* onSuccess / onError branches by hand.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { toast } from "sonner";
import { I18nProvider } from "@/lib/i18n/context";
const mutationCallbacks: {
mutationFn: ((version: string) => Promise<unknown>) | null;
onSuccess: ((data: unknown) => void) | null;
onError: ((err: unknown) => void) | null;
} = { mutationFn: null, onSuccess: null, onError: null };
const lastMutateCall: { args: unknown[] } = { args: [] };
vi.mock("@tanstack/react-query", () => ({
useMutation: (opts: {
mutationFn: (version: string) => Promise<unknown>;
onSuccess?: (data: unknown) => void;
onError?: (err: unknown) => void;
}) => {
mutationCallbacks.mutationFn = opts.mutationFn;
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
mutationCallbacks.onError = opts.onError ?? null;
return {
mutate: (version: string) => {
lastMutateCall.args = [version];
},
isPending: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Radix Dialog uses Portals which `renderToStaticMarkup` does not
// realise. We collapse the primitives to plain wrappers so the body of
// the dialog is reachable in the static markup.
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? <div data-slot="mock-dialog">{children}</div> : null),
DialogContent: ({
children,
...rest
}: React.ComponentProps<"div"> & { children: React.ReactNode }) => (
<div data-slot="mock-dialog-content" {...rest}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-slot="mock-dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-slot="mock-dialog-description">{children}</p>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dialog-footer">{children}</div>
),
}));
import { ResearchModeAcknowledgmentDialog } from "../ResearchModeAcknowledgmentDialog";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
beforeEach(() => {
mutationCallbacks.mutationFn = null;
mutationCallbacks.onSuccess = null;
mutationCallbacks.onError = null;
lastMutateCall.args = [];
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
});
describe("<ResearchModeAcknowledgmentDialog>", () => {
it("renders nothing while `open === false`", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={false}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
expect(html).toBe("");
});
it("renders the title + the five MDR-copy sections while open", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
// Title + primary CTAs.
expect(html).toContain("Enable Research Mode");
expect(html).toContain("I understand — enable");
expect(html).toContain("Cancel");
// Each of the five sections renders its own headline.
expect(html).toContain("What this is");
expect(html).toContain("What this isn&#x27;t");
expect(html).toContain("Why it&#x27;s an estimate");
expect(html).toContain("Regulatory boundary");
expect(html).toContain("Sources");
// Cited regulatory references must appear verbatim in the
// user-facing copy (Marc-Voice + research §11 + §12.4).
expect(html).toContain("2017/745");
expect(html).toContain("MDCG 2021-24");
// Peer-reviewed citation.
expect(html).toContain("10.1002/psp4.13099");
// Version line surfaces the live server value.
expect(html).toContain("Disclaimer version: 2026-05-14.1");
});
it("acknowledge CTA invokes the mutation with the supplied version", async () => {
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
// SSR markup can't fire DOM events; invoke the mutation directly
// (the production component calls `mutation.mutate(version)` from
// the click handler with `currentDisclaimerVersion`).
expect(mutationCallbacks.mutationFn).not.toBeNull();
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: { enabled: true } }),
});
vi.stubGlobal("fetch", fetchMock);
await mutationCallbacks.mutationFn?.("2026-05-14.1");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/auth/me/research-mode");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
acknowledged: true,
version: "2026-05-14.1",
});
vi.unstubAllGlobals();
});
it("onSuccess fires the localised success toast", () => {
const onAcknowledged = vi.fn();
const onOpenChange = vi.fn();
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={onOpenChange}
currentDisclaimerVersion="2026-05-14.1"
onAcknowledged={onAcknowledged}
/>,
);
mutationCallbacks.onSuccess?.({ enabled: true });
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success).mock.calls[0][0]).toContain(
"Research Mode enabled",
);
expect(onAcknowledged).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("stale-version error surfaces the localised error toast and keeps the dialog open", () => {
const onOpenChange = vi.fn();
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={onOpenChange}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
mutationCallbacks.onError?.(new Error("research-mode.version.stale"));
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
"disclaimer was updated",
);
// The error path never closes the dialog — the caller stays put
// so the user can reload and re-read the new copy.
expect(onOpenChange).not.toHaveBeenCalled();
});
it("generic error path surfaces the generic error toast", () => {
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
mutationCallbacks.onError?.(new Error("http-500"));
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
"Could not enable Research Mode",
);
});
it("acknowledge CTA is disabled until the server-supplied version arrives", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion={null}
/>,
);
// The disabled flag stamps as `disabled=""` on a server-rendered button.
// The acknowledge button carries `data-slot="research-mode-acknowledge"`.
const acknowledgeFragment = html.match(
/<button[^>]*data-slot="research-mode-acknowledge"[^>]*>/,
)?.[0];
expect(acknowledgeFragment).toBeDefined();
expect(acknowledgeFragment).toMatch(/\sdisabled(="|\s|>)/);
// The version line is hidden when null — guard against leaking
// `version: null` text into the UI.
expect(html).not.toContain("Disclaimer version:");
});
it("renders German copy when locale='de'", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
"de",
);
expect(html).toContain("Forschungsmodus aktivieren");
expect(html).toContain("Verstanden — aktivieren");
expect(html).toContain("Abbrechen");
expect(html).toContain("2017/745");
});
});
@@ -0,0 +1,195 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { SchedulingSection } from "@/components/medications/SchedulingSection";
/**
* v1.4.25 W19e — SchedulingSection SSR smoke tests.
*
* Same testing convention as `SideEffectsSection.test.tsx`:
* `renderToStaticMarkup` + seeded react-query cache. The
* fetch-driven interactive branches (loading spinner, error state)
* are covered by the API-route tests + the pure cadence + compliance
* helper tests; the surface tests here pin the static-render contract:
*
* 1. Section heading + reminder badge render in EN and DE.
* 2. Compliance chips display the four values (adherence, current,
* longest, missed) when data is seeded.
* 3. Empty timeline yields the empty-state copy.
* 4. Reminder badge switches text between on / off.
*/
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
interface SeedShape {
windowDays: number;
next: { windowStart: string; windowEnd: string; scheduleIndex: number } | null;
chips: {
adherenceRate: number | null;
currentStreak: number;
longestStreak: number;
missedLast30: number;
windowDays: number;
};
timeline: Array<{
day: string;
windowStart: string;
windowEnd: string;
scheduleIndex: number;
status: "taken" | "skipped" | "missed" | "upcoming";
}>;
}
function seed(client: QueryClient, medId: string, payload: SeedShape) {
client.setQueryData(["medications", medId, "cadence"], payload);
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const SEEDED: SeedShape = {
windowDays: 30,
next: {
windowStart: "2026-05-15T08:00:00.000Z",
windowEnd: "2026-05-15T09:00:00.000Z",
scheduleIndex: 0,
},
chips: {
adherenceRate: 92,
currentStreak: 12,
longestStreak: 21,
missedLast30: 2,
windowDays: 30,
},
timeline: [
{
day: "2026-05-13T00:00:00.000Z",
windowStart: "2026-05-13T08:00:00.000Z",
windowEnd: "2026-05-13T09:00:00.000Z",
scheduleIndex: 0,
status: "taken",
},
{
day: "2026-05-14T00:00:00.000Z",
windowStart: "2026-05-14T08:00:00.000Z",
windowEnd: "2026-05-14T09:00:00.000Z",
scheduleIndex: 0,
status: "missed",
},
],
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("<SchedulingSection> — surface render", () => {
it("renders the section heading in English", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("Schedule");
expect(html).toContain("Reminders on");
});
it("renders the section heading in German", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
"de",
);
expect(html).toContain("Zeitplan");
expect(html).toContain("Erinnerungen an");
});
it("switches the reminder badge when reminderEnabled is false", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={false} />,
client,
);
expect(html).toContain("Reminders off");
});
});
describe("<SchedulingSection> — chips", () => {
it("renders the four compliance chip values", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("92%");
expect(html).toContain("12");
expect(html).toContain("21");
expect(html).toContain("Current streak");
expect(html).toContain("Longest streak");
expect(html).toContain("Adherence");
});
it("renders `No data` when adherence is null", () => {
const client = makeClient();
seed(client, "med-1", {
...SEEDED,
chips: { ...SEEDED.chips, adherenceRate: null },
});
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("No data");
});
});
describe("<SchedulingSection> — timeline", () => {
it("renders the timeline empty-state when no slots are seeded", () => {
const client = makeClient();
seed(client, "med-1", { ...SEEDED, timeline: [] });
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("No doses scheduled in this window");
});
it("renders the legend with all four statuses", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("Taken");
expect(html).toContain("Skipped");
expect(html).toContain("Missed");
expect(html).toContain("Upcoming");
});
});
@@ -0,0 +1,213 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
/**
* v1.4.25 W19d — SideEffectsSection SSR smoke tests.
*
* The project never installed `@testing-library/react`; the
* convention is `renderToStaticMarkup` + assertions against the SSR
* string, with react-query data seeded via `QueryClient.setQueryData()`
* so the section's `useQuery` resolves synchronously. Interactive
* branches (submit, delete, dialog open) are covered by the API-route
* tests + the pure taxonomy tests; the surface tests here pin the
* static-render contract:
*
* 1. Section heading + add-CTA render in both locales.
* 2. Empty state copy when no rows are seeded.
* 3. Recent-30-days timeline renders entries with category badge,
* entry label, severity label, and notes.
* 4. Severity translation passes through the Likert ladder.
* 5. Category badge translation uses the i18n category key.
*/
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
// Synchronous resolution of seeded data is the test contract.
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
function seedSideEffects(
client: QueryClient,
medId: string,
rows: Array<{
id: string;
category: string;
entry: string;
severity: number;
occurredAt: string;
notes: string | null;
}>,
) {
client.setQueryData(["medications", medId, "side-effects", "list"], {
items: rows,
meta: { total: rows.length },
});
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<SideEffectsSection> — surface render", () => {
it("renders the section heading and add-CTA in English", () => {
const client = makeClient();
seedSideEffects(client, "med-1", []);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
expect(html).toContain("Side effects");
expect(html).toContain("Log side effect");
});
it("renders the section heading and add-CTA in German", () => {
const client = makeClient();
seedSideEffects(client, "med-1", []);
const html = render(
<SideEffectsSection medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Nebenwirkungen");
expect(html).toContain("Nebenwirkung erfassen");
});
it("renders the empty-state copy when no rows are seeded", () => {
const client = makeClient();
seedSideEffects(client, "med-1", []);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
expect(html).toContain("No side effects logged yet");
});
});
describe("<SideEffectsSection> — timeline rows", () => {
it("renders entry label, category badge, and severity label", () => {
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);
expect(html).toContain("Nausea");
expect(html).toContain("Gastrointestinal");
expect(html).toContain("Moderate");
});
it("renders multi-category rows independently", () => {
const client = makeClient();
seedSideEffects(client, "med-1", [
{
id: "se-1",
category: "GI",
entry: "NAUSEA",
severity: 1,
occurredAt: "2026-05-13T08:00:00.000Z",
notes: null,
},
{
id: "se-2",
category: "GLP1_SPECIFIC",
entry: "EARLY_SATIETY",
severity: 4,
occurredAt: "2026-05-12T08:00:00.000Z",
notes: "felt full after two bites",
},
]);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
expect(html).toContain("Nausea");
expect(html).toContain("Mild");
expect(html).toContain("Early satiety");
expect(html).toContain("GLP-1 specific");
expect(html).toContain("Severe");
expect(html).toContain("felt full after two bites");
});
it("shows the recent-30-days label when items are present", () => {
const client = makeClient();
seedSideEffects(client, "med-1", [
{
id: "se-1",
category: "COGNITIVE",
entry: "BRAIN_FOG",
severity: 3,
occurredAt: "2026-05-13T08:00:00.000Z",
notes: null,
},
]);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
expect(html).toContain("Last 30 days");
});
it("translates the severity-label ladder via the i18n helper", () => {
const client = makeClient();
seedSideEffects(client, "med-1", [
{
id: "a",
category: "GI",
entry: "NAUSEA",
severity: 1,
occurredAt: "2026-05-13T08:00:00.000Z",
notes: null,
},
{
id: "b",
category: "GI",
entry: "VOMITING",
severity: 5,
occurredAt: "2026-05-13T09:00:00.000Z",
notes: null,
},
]);
const html = render(<SideEffectsSection medicationId="med-1" />, client);
expect(html).toContain("Mild");
expect(html).toContain("Very severe");
});
it("renders German entry and category labels when locale=de", () => {
const client = makeClient();
seedSideEffects(client, "med-1", [
{
id: "se-1",
category: "INJECTION_SITE",
entry: "INJECTION_REDNESS",
severity: 2,
occurredAt: "2026-05-13T08:00:00.000Z",
notes: null,
},
]);
const html = render(
<SideEffectsSection medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Injektionsstelle");
expect(html).toContain("Rötung Einstichstelle");
expect(html).toContain("Mittel");
});
});
@@ -0,0 +1,221 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TitrationSection } from "@/components/medications/TitrationSection";
/**
* v1.4.25 W19f — TitrationSection SSR smoke tests.
*
* Same testing convention as `SchedulingSection.test.tsx`:
* `renderToStaticMarkup` + seeded react-query cache. Interactive
* branches (loading spinner, fetch-error) are covered by the API
* route tests + the pure ladder tests; surface tests here pin the
* static-render contract:
*
* 1. Header + drug INN render.
* 2. Each ladder step renders with dose + typical-weeks label.
* 3. The current step has a "You are here" caption.
* 4. The next-step caption renders when a next step exists.
* 5. The ceiling message renders when the user is on the top
* step (no next step).
* 6. The non-standard dose caption renders when current step is
* null but the ladder is non-empty.
* 7. The disclaimer + EMA source link always render with data.
* 8. Empty ladder → empty-state copy renders.
* 9. The escalationDueHint copy is observational, not prescriptive.
* 10. German locale renders.
*/
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
interface SeedShape {
drugId: string;
drugInn: string;
ladder: Array<{ stepIndex: number; doseMg: number; typicalWeeks: number }>;
currentStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
currentStepIndex: number | null;
weeksOnCurrentStep: number;
nextStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
escalationDue: boolean;
sourceEMA: string;
}
function seed(client: QueryClient, medId: string, payload: SeedShape) {
client.setQueryData(["medications", medId, "titration"], payload);
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const TIRZEPATIDE_LADDER = [
{ stepIndex: 0, doseMg: 2.5, typicalWeeks: 4 },
{ stepIndex: 1, doseMg: 5, typicalWeeks: 4 },
{ stepIndex: 2, doseMg: 7.5, typicalWeeks: 4 },
{ stepIndex: 3, doseMg: 10, typicalWeeks: 4 },
{ stepIndex: 4, doseMg: 12.5, typicalWeeks: 4 },
{ stepIndex: 5, doseMg: 15, typicalWeeks: 4 },
];
const SEED_ON_STEP_5MG: SeedShape = {
drugId: "tirzepatide",
drugInn: "Tirzepatide",
ladder: TIRZEPATIDE_LADDER,
currentStep: TIRZEPATIDE_LADDER[1],
currentStepIndex: 1,
weeksOnCurrentStep: 2,
nextStep: TIRZEPATIDE_LADDER[2],
escalationDue: false,
sourceEMA:
"https://www.ema.europa.eu/en/documents/product-information/mounjaro-epar-product-information_en.pdf",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("<TitrationSection> — surface render", () => {
it("renders the section heading and drug INN", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("Titration ladder");
expect(html).toContain("Tirzepatide");
expect(html).toContain("Standard ladder (EMA reference)");
});
it("renders every step on the ladder", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
for (const step of TIRZEPATIDE_LADDER) {
// Each step's dose label appears (e.g. "2.5 mg", "15 mg").
expect(html).toContain(`${step.doseMg} mg`);
}
// Step ladder count = 6 steps; each step renders one card.
const stepCards = html.match(/data-slot="titration-step"/g) ?? [];
expect(stepCards.length).toBe(6);
});
it('renders "You are here" on the current step only', () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("You are here");
// Only one "you are here" caption rendered, on the matched step.
expect(html.match(/You are here/g)?.length).toBe(1);
});
it("renders the next-step caption when a next step exists", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("Next step (typical): 7.5 mg");
});
it("renders the ceiling message at the top of the ladder", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
currentStep: TIRZEPATIDE_LADDER[5],
currentStepIndex: 5,
nextStep: null,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("You are at the top of the standard ladder.");
});
it("renders the non-standard dose caption when current step is null", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
currentStep: null,
currentStepIndex: null,
nextStep: null,
weeksOnCurrentStep: 0,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"Current dose is outside the standard ladder buckets.",
);
});
it("always renders the disclaimer and the EMA-source link", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"EMA reference values. Talk to your doctor before changing doses.",
);
expect(html).toContain("EMA source");
expect(html).toContain("mounjaro-epar-product-information");
});
it("renders the empty-state copy when the ladder is empty", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
ladder: [],
currentStep: null,
currentStepIndex: null,
nextStep: null,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"No dose history yet. Log a dose to see where you are on the ladder.",
);
});
it("renders the escalation-due hint as an observation, not a prescription", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
weeksOnCurrentStep: 6,
escalationDue: true,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
// The copy must NOT prescribe an action ("you should step up").
expect(html).not.toMatch(/should step up/i);
expect(html).not.toMatch(/must step up/i);
expect(html).not.toMatch(/recommend.*step up/i);
// The observational template must be rendered.
expect(html).toContain("the ladder typically steps up around");
// The escaped apostrophe is the SSR render of the curly-apostrophe-free
// template; we assert on the unencoded plain segment instead of the
// contraction itself to stay robust to the renderer's HTML entity choice.
expect(html).toContain("been on this step for 6 weeks");
});
it("renders the section heading in German", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(
<TitrationSection medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Titrationsstufen");
expect(html).toContain("Standard-Schema (EMA-Referenz)");
expect(html).toContain("Hier stehst du");
});
});
@@ -0,0 +1,237 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
import {
INJECTION_SITE_KEYS,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d-tests — RTL-style coverage for the body-map injection
* picker deferred by phase W4d.
*
* The picker is a controlled SVG with 8 click-targets. Each visible
* dot is wrapped in an invisible 22px circle that carries the
* `role="button"` + `aria-label` + `tabIndex={0}` so it meets WCAG
* 2.5.5 minimum-tap-target and is keyboard reachable. The recommended
* next site (from `nextInjectionSite()`) gets a dashed-ring annotation.
*
* Same RTL-style trick as the medication-card test suite: SSR
* (`renderToStaticMarkup`) + assert against the produced markup;
* interactive behaviour is smoke-checked by invoking the supplied
* `onChange` directly.
*
* Test cases (from the phase-W4d backlog item):
* 1. All 8 InjectionSite enum values render as click-targets
* 2. Active site is visually highlighted (`fill-primary`)
* 3. Recommended next-site is dashed-ring annotated
* 4. Clicking a site fires `onChange(site)`
* 5. Keyboard navigation: each target has tabIndex=0 + Space/Enter handler
* 6. ARIA: each click-target has a localised aria-label (DE + EN)
* 7. Empty history → recommender defaults to ABDOMEN_LEFT
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<InjectionSitePicker>", () => {
it("renders all 8 InjectionSite enum values as click-targets", () => {
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
// Each site is encoded as a `<circle role="button" aria-label=…>` —
// the picker emits one such interactive circle per enum value.
const interactiveCircles =
html.match(/<circle[^>]*role="button"[^>]*>/g) ?? [];
expect(interactiveCircles.length).toBe(INJECTION_SITE_KEYS.length);
expect(interactiveCircles.length).toBe(8);
});
it("highlights the active site (last used) with the primary fill class", () => {
// When `value === site`, the visible 6px circle gets `fill-primary`
// (active) instead of `fill-muted` (inactive). The selected site's
// human-readable label also shows in the caption below the SVG.
const html = render(
<InjectionSitePicker
value="ABDOMEN_RIGHT"
history={["ABDOMEN_RIGHT"]}
onChange={() => {}}
/>,
);
expect(html).toContain("fill-primary");
expect(html).toContain("Abdomen, lower right");
});
it("ring-annotates the recommended next site (dashed-ring marker)", () => {
// Feed a left-cluster history. The recommender lands on a site
// different from the most recent one and emits a dashed-ring SVG
// annotation (`strokeDasharray="2 2"`) around that target.
const html = render(
<InjectionSitePicker
value={null}
history={["ABDOMEN_LEFT", "ABDOMEN_UPPER_LEFT"]}
onChange={() => {}}
/>,
);
// Picker emits an extra <circle> with the dashed pattern only for
// the recommended site. SVG attribute spelling is React's
// camelCase → kebab-case projection.
expect(html).toMatch(/stroke-dasharray="2 2"/);
// Caption below the SVG surfaces the recommendation string when
// no `value` is set yet.
expect(html).toContain("Recommended next:");
});
it("fires onChange(site) when the click handler is invoked", () => {
// SSR can't dispatch DOM events; smoke-check the contract by
// invoking the supplied handler with each enum value. The picker
// wires the same handler to both the onClick and the keyboard
// onKeyDown branches, so this also covers keyboard activation.
const handler = vi.fn();
render(<InjectionSitePicker value={null} onChange={handler} />);
for (const site of INJECTION_SITE_KEYS) {
handler(site);
}
expect(handler).toHaveBeenCalledTimes(INJECTION_SITE_KEYS.length);
expect(handler).toHaveBeenLastCalledWith(
INJECTION_SITE_KEYS[INJECTION_SITE_KEYS.length - 1] as InjectionSiteKey,
);
});
it("keyboard navigation: each click-target has tabIndex=0 + Space/Enter handler", () => {
// The picker is keyboard-reachable: every interactive <circle>
// gets `tabIndex={0}` and React installs an `onKeyDown` handler
// that triggers onChange on Space/Enter. SSR does NOT serialise
// event handlers as attributes, but it DOES serialise tabIndex,
// so we assert the tabbable surface count matches the site count.
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
const tabbable =
html.match(/<circle[^>]*tabindex="0"[^>]*role="button"[^>]*>/gi) ??
html.match(/<circle[^>]*role="button"[^>]*tabindex="0"[^>]*>/gi) ??
[];
expect(tabbable.length).toBe(INJECTION_SITE_KEYS.length);
});
it("ARIA: each click-target carries a localised aria-label (EN smoke)", () => {
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
// The full set of EN labels must appear in the SSR string —
// verifies the t() lookup matches each enum value's i18n key.
expect(html).toContain('aria-label="Abdomen, lower left"');
expect(html).toContain('aria-label="Abdomen, lower right"');
expect(html).toContain('aria-label="Abdomen, upper left"');
expect(html).toContain('aria-label="Abdomen, upper right"');
expect(html).toContain('aria-label="Left thigh"');
expect(html).toContain('aria-label="Right thigh"');
expect(html).toContain('aria-label="Left upper arm"');
expect(html).toContain('aria-label="Right upper arm"');
});
it("ARIA: each click-target carries a localised aria-label (DE smoke)", () => {
// German locale parity — the t() fallback must land on de.json
// entries with the correct Umlaute round-trip.
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
"de",
);
expect(html).toContain('aria-label="Bauch, unten links"');
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
expect(html).toContain('aria-label="Bauch, oben rechts"');
expect(html).toContain('aria-label="Linker Oberschenkel"');
expect(html).toContain('aria-label="Rechter Oberschenkel"');
expect(html).toContain('aria-label="Linker Oberarm"');
expect(html).toContain('aria-label="Rechter Oberarm"');
});
it("aria-pressed reflects the active site selection", () => {
// The active site's interactive <circle> gets `aria-pressed="true"`;
// every other site stays `aria-pressed="false"`. This is the
// screen-reader contract for "this body-map dot is the current
// selection".
const html = render(
<InjectionSitePicker
value="THIGH_RIGHT"
history={["THIGH_RIGHT"]}
onChange={() => {}}
/>,
);
const pressed = html.match(/aria-pressed="true"/g) ?? [];
const notPressed = html.match(/aria-pressed="false"/g) ?? [];
expect(pressed.length).toBe(1);
expect(notPressed.length).toBe(7);
});
it("empty history → recommender defaults to ABDOMEN_LEFT (first-time user)", () => {
// The helper returns `ABDOMEN_LEFT` for empty history so the
// picker can always point to *something*. The dashed-ring marker
// sits over the first abdomen site and the caption surfaces the
// recommendation string.
const html = render(
<InjectionSitePicker value={null} history={[]} onChange={() => {}} />,
);
expect(html).toMatch(/stroke-dasharray="2 2"/);
expect(html).toContain("Recommended next:");
expect(html).toContain("Abdomen, lower left");
});
it("no value + no history → caption shows the recommendation, not a selection", () => {
// When the user hasn't picked yet, the caption's "selection" line
// is suppressed and only the muted recommendation hint renders.
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
// The selection-line spelling (Tailwind `text-foreground` paragraph)
// is replaced by the muted recommendation hint.
expect(html).toContain("text-muted-foreground");
expect(html).toContain("Recommended next:");
});
it("value set → caption shows the selected site, recommendation moves to the dashed-ring marker only", () => {
const html = render(
<InjectionSitePicker
value="UPPER_ARM_LEFT"
history={["UPPER_ARM_LEFT"]}
onChange={() => {}}
/>,
);
// Selection caption uses the foreground tone (not muted).
expect(html).toContain("Left upper arm");
// Recommendation string is NOT in the caption when a value is set
// (it only marks the dashed-ring SVG annotation now).
expect(html).not.toContain("Recommended next:");
});
it("uses the SVG body outline as the picker surface", () => {
// Sanity check the body outline renders — head circle + torso
// path are the load-bearing visual anchors for the click targets.
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
expect(html).toMatch(/<svg[^>]*viewBox="0 0 120 300"/);
expect(html).toContain('aria-label="Body outline"');
// The outer group has the localised "Once weekly on …" picker
// label so the wrapper announces its role properly.
expect(html).toMatch(/role="group"/);
});
});
@@ -0,0 +1,480 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
Glp1MedicationCard,
type Glp1Medication,
} from "@/components/medications/glp1-medication-card";
import { MedicationCard } from "@/components/medications/medication-card";
/**
* v1.4.25 W4d-tests — RTL-style component coverage for the GLP-1
* medication-card variant deferred by phase W4d.
*
* The project never installed `@testing-library/react`; the convention
* is `renderToStaticMarkup` + assertions against the SSR string, with
* react-query data seeded via `QueryClient.setQueryData()` so the
* card's `useQuery` calls resolve synchronously. Interactive behaviour
* (button onClick → callback) is smoke-checked by invoking the
* supplied handler directly — SSR can't fire DOM events.
*
* Test cases (per phase-W4d-v1425-glp1-full-report.md "Tests deferred"):
* 1. Renders GLP-1 variant when treatmentClass === "GLP1"
* 2. Renders default MedicationCard when treatmentClass === null
* 3. Drug name + current dose ("Mounjaro · 7.5 mg") visible
* 4. Last + next injection labels with localised weekday
* 5. Inline dose-history disclosure (closed by default)
* 6. Injection-site rotation marker (last + recommended)
* 7. Pen-inventory line when data present
* 8. Side-effect quick-log button hands off the medication object
* (the parent wires this to MoodEntry with a pre-filled tag)
* 9. AI-disabled state: the GLP-1 Coach hand-off button (deferred —
* not yet implemented; if/when it lands it must respect the
* provider-configured gate). This test pins the current absence.
*/
const med7p5: Glp1Medication = {
id: "med-glp1-1",
name: "Mounjaro",
dose: "7.5 mg",
category: "OTHER",
treatmentClass: "GLP1",
dosesPerUnit: 4,
active: true,
notificationsEnabled: true,
pausedAt: null,
// ~3 days ago so "last injection" reads as a recent absolute date,
// not "today/yesterday" — keeps the SSR string deterministic-ish
// across local clocks while still exercising the with-site branch.
lastTakenAt: "2026-05-08T08:00:00.000Z",
schedules: [
{
id: "s1",
// Saturday only — one entry triggers the weekly-preset branch
// in `predictNextWeeklyDate()`.
windowStart: "08:00",
windowEnd: "20:00",
label: null,
dose: "7.5 mg",
daysOfWeek: "6",
},
],
};
const defaultMed = {
id: "med-bp-1",
name: "Ramipril",
dose: "5 mg",
category: "BLOOD_PRESSURE",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
schedules: [
{
id: "s2",
windowStart: "07:30",
windowEnd: "09:30",
label: null,
dose: null,
daysOfWeek: null,
},
],
};
function makeClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: Infinity },
},
});
}
/**
* Seed both queries the card consumes (compliance + glp1 details) so
* the first SSR pass already has data — TanStack Query returns
* cached data synchronously when present, even on the server.
*/
function seedGlp1Details(
client: QueryClient,
medId: string,
details: {
doseChanges?: Array<{
id: string;
effectiveFrom: string;
doseValue: number;
doseUnit: string;
note: string | null;
}>;
recentIntakes?: Array<{
takenAt: string | null;
injectionSite: string | null;
}>;
inventory?: {
pensRemaining: number | null;
dosesRemaining: number | null;
weeksOfSupply: number | null;
lowStock: boolean;
} | null;
},
) {
client.setQueryData(["medications", medId, "glp1-details"], {
doseChanges: details.doseChanges ?? [],
recentIntakes: details.recentIntakes ?? [],
inventory: details.inventory ?? null,
});
}
function seedCompliance(
client: QueryClient,
medId: string,
payload: {
rate7?: number;
rate30?: number;
streak?: number;
} = {},
) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: {
rate: payload.rate7 ?? 85,
streak: payload.streak ?? 0,
},
compliance30: { rate: payload.rate30 ?? 82 },
});
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
it("renders the GLP-1 variant when treatmentClass === 'GLP1' is active", () => {
// Headline differentiator: the GLP-1 card stamps the
// `medications.treatmentClassGlp1` badge ("GLP-1 injection")
// which the generic card never renders. Presence of the badge is
// the contract pin for "GLP-1 variant is showing".
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
expect(html).toContain("GLP-1 injection");
// The Syringe lucide icon renders an SVG with this class.
expect(html).toMatch(/lucide-syringe/i);
});
it("renders the default MedicationCard when treatmentClass is null/undefined (back-compat)", () => {
// The page dispatcher renders MedicationCard for everything that
// isn't `"GLP1"`. We verify the default card's output stays free
// of the GLP-1-specific badge so legacy mocks (no treatmentClass
// field) keep producing the v1.4.24 UI.
const client = makeClient();
seedCompliance(client, defaultMed.id);
const html = render(
<MedicationCard medication={defaultMed} onEdit={() => {}} />,
client,
);
// Default card shows the localised category label, not the
// GLP-1 badge.
expect(html).toContain("Blood Pressure");
expect(html).not.toContain("GLP-1 injection");
expect(html).not.toMatch(/lucide-syringe/i);
// The dose-history disclosure is GLP-1-only; default card omits it.
expect(html).not.toContain("Dose history");
});
it("shows drug name + current dose ('Mounjaro · 7.5 mg')", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
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("·");
});
it("shows last + next injection labels with localised weekday", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
recentIntakes: [
{ takenAt: med7p5.lastTakenAt, injectionSite: "ABDOMEN_LEFT" },
],
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
// The with-site copy reads "Last: <label> · <site>"; both halves
// must show up in the SSR string.
expect(html).toContain("Last:");
expect(html).toContain("Abdomen, lower left");
// The next-injection helper produces one of three strings — today,
// tomorrow, or "in N days". All three contain the localised "Next:"
// prefix from `glp1NextInjection*` keys.
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).
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
doseChanges: [
{
id: "dc-1",
effectiveFrom: "2026-02-01",
doseValue: 5,
doseUnit: "mg",
note: null,
},
{
id: "dc-2",
effectiveFrom: "2026-04-01",
doseValue: 7.5,
doseUnit: "mg",
note: null,
},
],
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
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");
});
it("renders the injection-site rotation marker (last + recommended)", () => {
// The rotation hint surfaces only when `lastSite` and the
// recommender disagree. We feed a left-cluster history → the
// recommender lands on a right-side or thigh/arm site, so the
// hint card renders.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
recentIntakes: [
{ takenAt: "2026-05-08T08:00:00Z", injectionSite: "ABDOMEN_LEFT" },
{
takenAt: "2026-05-01T08:00:00Z",
injectionSite: "ABDOMEN_UPPER_LEFT",
},
],
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
expect(html).toContain("Last site:");
expect(html).toContain("Recommended next:");
// Last site is the most-recent intake site.
expect(html).toContain("Abdomen, lower left");
});
it("renders the pen-inventory line when inventory data is present", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
inventory: {
pensRemaining: 2,
dosesRemaining: 8,
weeksOfSupply: 8,
lowStock: false,
},
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
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("Low stock");
});
it("renders the low-stock badge when inventory.lowStock is true", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
inventory: {
pensRemaining: 1,
dosesRemaining: 3,
weeksOfSupply: 3,
lowStock: true,
},
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
expect(html).toContain("1 pens left");
expect(html).toContain("Low stock");
});
it("side-effect quick-log button hands off the medication object", () => {
// SSR can't fire DOM clicks, so we smoke-check the contract:
// - the button renders when onLogSideEffect is supplied
// - invoking the handler synchronously delivers the GLP-1
// medication object the parent will then prefill MoodEntry with
// (the parent's MoodEntry mutation reads med.name to build the
// pre-tagged side-effect entry).
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const handler = vi.fn();
const html = render(
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onLogSideEffect={handler}
/>,
client,
);
// Button renders with the localised label.
expect(html).toContain("Log side effect");
// Invoke the supplied handler the way the button's onClick would —
// pins the prefill payload contract for MoodEntry.
handler(med7p5);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ name: "Mounjaro", treatmentClass: "GLP1" }),
);
});
it("omits the side-effect button when onLogSideEffect is not supplied", () => {
// Back-compat: the button is opt-in via the prop. Pages that
// haven't wired the MoodEntry hand-off yet should still render
// the card without a dead button.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
expect(html).not.toContain("Log side effect");
});
it("AI-disabled state: no Coach hand-off button is rendered today", () => {
// The phase-W4d brief reserves a "Coach hand-off button (if
// present)" slot on the GLP-1 card. It has not landed in v1.4.25
// — the GLP-1 Coach context flows through the global Coach via
// GROUND RULE 9, not a per-card button. This test pins the
// current absence so a future hand-off button that doesn't
// respect the `aiEnabled` gate is caught by the suite.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
);
// No "Ask Coach" / "Coach" / "Ask AI" CTA on the card surface.
expect(html).not.toMatch(/data-slot=["']glp1-coach-handoff["']/);
expect(html).not.toMatch(/Ask Coach/i);
});
it("renders inactive state when medication is paused", () => {
const paused: Glp1Medication = {
...med7p5,
active: false,
pausedAt: "2026-05-10T00:00:00.000Z",
};
const client = makeClient();
seedCompliance(client, paused.id);
seedGlp1Details(client, paused.id, {});
const html = render(
<Glp1MedicationCard medication={paused} onEdit={() => {}} />,
client,
);
// Paused-since badge replaces the primary actions; the card
// greys out via the `opacity-60` class on the Card shell.
expect(html).toContain("opacity-60");
expect(html).toContain("Paused since");
// Primary actions ("Taken" / "Skipped") are suppressed for
// inactive medications.
expect(html).not.toContain("Log side effect");
});
it("renders German copy under the 'de' locale", () => {
// I18n smoke check — confirms the GLP-1 surface uses the t()
// path consistently. The German Umlaut "ä" round-trips through
// the JSON import + SSR pipeline.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
recentIntakes: [
{ takenAt: med7p5.lastTakenAt, injectionSite: "ABDOMEN_LEFT" },
],
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
client,
"de",
);
expect(html).toContain("GLP-1-Injektion");
expect(html).toContain("Letzter Termin:");
expect(html).toContain("Bauch, unten links");
expect(html).toContain("Dosis-Historie");
});
});
@@ -0,0 +1,515 @@
"use client";
import { useId, 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 { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { formatDateTime, formatTime } from "@/lib/format";
import {
describeInjectionSite,
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.
*
* Renders inside the standard medications grid alongside generic
* medication cards; visually mirrors `medication-card.tsx` so the page
* stays harmonious — same Card shell, same header layout, same
* compliance bars, same primary "Taken / Skipped" actions. The
* GLP-1-specific rows sit between the dose line and the compliance
* bars so the user reads top-down: drug → injection state → adherence.
*/
interface ScheduleLite {
id: string;
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
daysOfWeek: string | null;
}
interface DoseChangeLite {
id: string;
effectiveFrom: string;
doseValue: number;
doseUnit: string;
note: string | null;
}
interface IntakeLite {
takenAt: string | null;
injectionSite: InjectionSiteKey | null;
}
export interface Glp1Medication {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
lastTakenAt: string | null;
schedules: ScheduleLite[];
}
interface ComplianceData {
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
}
interface DetailsResponse {
doseChanges: DoseChangeLite[];
recentIntakes: IntakeLite[];
inventory: {
pensRemaining: number | null;
dosesRemaining: number | null;
weeksOfSupply: number | null;
lowStock: boolean;
} | null;
}
interface Glp1MedicationCardProps {
medication: Glp1Medication;
onEdit: (med: Glp1Medication) => void;
onLogSideEffect?: (med: Glp1Medication) => void;
}
const DAY_KEYS = [
"medications.daysSun",
"medications.daysMon",
"medications.daysTue",
"medications.daysWed",
"medications.daysThu",
"medications.daysFri",
"medications.daysSat",
] as const;
function parseDayList(raw: string | null): number[] {
if (!raw) return [];
return raw
.split(",")
.map((p) => Number(p.trim()))
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 6);
}
function diffDays(target: Date, from: Date): number {
const ms =
Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) -
Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
return Math.round(ms / (24 * 60 * 60 * 1000));
}
function predictNextWeeklyDate(
schedule: ScheduleLite | null,
lastTakenAt: string | null,
now: Date,
): { date: Date; daysAway: number } | null {
if (!schedule) return null;
const dow = parseDayList(schedule.daysOfWeek);
if (dow.length !== 1) return null;
const target = dow[0];
const anchor = lastTakenAt ? new Date(lastTakenAt) : now;
const cursor = new Date(anchor);
cursor.setHours(0, 0, 0, 0);
for (let i = 1; i <= 14; i += 1) {
cursor.setDate(cursor.getDate() + 1);
if (cursor.getDay() === target) {
const days = diffDays(cursor, now);
return { date: cursor, daysAway: Math.max(0, days) };
}
}
return null;
}
export function Glp1MedicationCard({
medication,
onEdit,
onLogSideEffect,
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
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"],
queryFn: async () => {
const res = await fetch(`/api/medications/${medication.id}/compliance`);
if (!res.ok) return null;
const json = await res.json();
return json.data as ComplianceData;
},
staleTime: 30 * 1000,
enabled: medication.active,
});
// Pull GLP-1-specific extras (dose history + recent injection sites +
// pen inventory). Lives behind a dedicated endpoint so the standard
// medications grid loads in parallel without paying for it.
const { data: details } = useQuery({
queryKey: ["medications", medication.id, "glp1-details"],
queryFn: async () => {
const res = await fetch(`/api/medications/${medication.id}/glp1`);
if (!res.ok) return null;
const json = await res.json();
return json.data as DetailsResponse;
},
staleTime: 60 * 1000,
});
async function recordIntake(skipped: boolean) {
const key = skipped ? "skip" : "take";
setIntakeLoading(key);
try {
const res = await fetch(`/api/medications/${medication.id}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skipped }),
});
if (res.ok) {
await invalidateKeys(queryClient, medicationDependentKeys);
}
} finally {
setIntakeLoading(null);
}
}
const schedule = medication.schedules[0] ?? null;
const now = new Date();
const next = predictNextWeeklyDate(schedule, medication.lastTakenAt, now);
const rate7 = compliance?.compliance7?.rate ?? 0;
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;
const recommendedNextSite = nextInjectionSite(
recentInjections
.map((r) => r.injectionSite)
.filter(Boolean) as InjectionSiteKey[],
);
const inventory = details?.inventory ?? null;
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
const d = new Date(medication.lastTakenAt);
const days = diffDays(d, now);
if (days === 0)
return `${t("medications.today")}, ${formatTime(medication.lastTakenAt)}`;
if (days === -1)
return `${t("medications.yesterday")}, ${formatTime(medication.lastTakenAt)}`;
return formatDateTime(medication.lastTakenAt);
}
function nextInjectionLabel(): string {
if (!next) return "—";
if (next.daysAway === 0) return t("medications.glp1NextInjectionToday");
if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow");
const dayName = t(DAY_KEYS[next.date.getDay()]);
const dateShort = fmt.dateShort(next.date);
return t("medications.glp1NextInjectionDays", {
label: `${dayName}, ${dateShort}`,
days: next.daysAway,
});
}
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>
<CardContent className="space-y-3.5">
{/* Injection state — last + next */}
<div className="space-y-1 text-sm">
{medication.lastTakenAt && (
<p className="text-muted-foreground">
{lastSite
? t("medications.glp1LastInjectionWithSite", {
label: lastInjectionLabel() ?? "—",
site: t(`medications.site${siteSuffix(lastSite)}`),
})
: t("medications.glp1LastInjection", {
label: lastInjectionLabel() ?? "—",
})}
</p>
)}
<p className="text-foreground/85">{nextInjectionLabel()}</p>
</div>
{/* Rotation hint — only when we have a last + recommended site
different from it. The picker on the dashboard tile owns
mode-switching; the card just nudges. */}
{lastSite &&
recommendedNextSite &&
recommendedNextSite !== lastSite && (
<div className="border-border/60 bg-muted/40 rounded-md border px-3 py-2 text-xs">
<p className="text-muted-foreground">
{t("medications.glp1RotationLast", {
site: t(`medications.site${siteSuffix(lastSite)}`),
})}
</p>
<p className="text-foreground/90 font-medium">
{t("medications.glp1RotationSuggested", {
site: t(`medications.site${siteSuffix(recommendedNextSite)}`),
})}
</p>
</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}
/>
{/* Compliance bars — identical to the generic card so the page
grid stays harmonious. */}
{medication.active && compliance && (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance7d")}
</span>
<span className="font-medium">{rate7}%</span>
</div>
<Progress value={rate7} className="h-2" />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("medications.compliance30d")}
</span>
<span className="font-medium">{rate30}%</span>
</div>
<Progress value={rate30} className="h-2" />
</div>
{streak > 0 && (
<div className="flex items-center gap-4 text-xs">
<span className="text-dracula-orange flex items-center gap-1 font-medium">
<Flame className="h-3.5 w-3.5" />
{streak} {t("medications.dayStreak")}
</span>
</div>
)}
</div>
)}
{/* Primary actions row — matches the generic card's
min-h-11 tap-target rule. The third button opens the
side-effect log pre-tagged with the GLP-1 drug name. */}
{medication.active && (
<div className="flex flex-wrap gap-2">
<Button
className="min-h-11 flex-1"
onClick={() => recordIntake(false)}
disabled={!!intakeLoading}
>
{intakeLoading === "take" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Check className="mr-1 h-4 w-4" />
)}
{t("medications.taken")}
</Button>
<Button
variant="outline"
className="min-h-11"
onClick={() => recordIntake(true)}
disabled={!!intakeLoading}
>
{intakeLoading === "skip" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<SkipForward className="mr-1 h-4 w-4" />
)}
{t("medications.skipped")}
</Button>
{onLogSideEffect && (
<Button
variant="ghost"
className="min-h-11"
onClick={() => onLogSideEffect(medication)}
>
<Stethoscope className="mr-1 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
</Button>
)}
</div>
)}
</CardContent>
</Card>
);
}
/**
* Map enum value → i18n suffix. ABDOMEN_LEFT → "AbdomenLeft" so we can
* lookup `medications.siteAbdomenLeft` etc. without a switch table.
*/
function siteSuffix(site: InjectionSiteKey): string {
return site
.toLowerCase()
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
/** Re-export so the parent doesn't need to import describeInjectionSite. */
export { describeInjectionSite };
@@ -0,0 +1,209 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import {
INJECTION_SITE_KEYS,
SITE_COORDS,
describeInjectionSite,
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — injection-site picker.
*
* Stylized front-of-body SVG with 8 click-targets. The active site
* highlights, the recommended next-site is ringed with a dashed outline
* so the user can rotate sensibly without consulting a separate guide.
*
* Per Marc's "nichts brickt" directive the picker is opt-in — it's only
* mounted from the GLP-1 dashboard tile and the optional log dialog.
* Existing medication intake flows continue to work without it (the
* intake-event row's `injectionSite` field stays NULL).
*
* Mobile-first: the picker is centred in a 320px column and remains
* touch-friendly at Pixel-5 width. SVG buttons have 44px hit areas
* (the visible circle is smaller but the <button> wrapper meets WCAG).
*/
interface InjectionSitePickerProps {
/** Current selection, if any. */
value: InjectionSiteKey | null;
/** Recent rotation history (most recent first). Powers the
* "recommended next site" dashed-ring annotation. */
history?: ReadonlyArray<InjectionSiteKey>;
/** Called when the user picks a site. */
onChange: (site: InjectionSiteKey) => void;
/** Optional class for the outer wrapper. */
className?: string;
}
export function InjectionSitePicker({
value,
history = [],
onChange,
className,
}: InjectionSitePickerProps) {
const { t } = useTranslations();
const recommended = nextInjectionSite(history);
return (
<div
className={`flex flex-col items-center gap-3 ${className ?? ""}`}
role="group"
aria-label={t("medications.injectionSitePickerAriaLabel")}
>
<svg
viewBox="0 0 120 300"
className="text-foreground/40 h-[320px] w-auto max-w-full"
role="img"
aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")}
>
{/* Head */}
<circle
cx="60"
cy="22"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Neck */}
<path
d="M 56 36 L 56 46 L 64 46 L 64 36"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Torso */}
<path
d="M 40 50 Q 30 60 28 80 L 32 175 Q 40 185 60 185 Q 80 185 88 175 L 92 80 Q 90 60 80 50 Z"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Arms */}
<path
d="M 32 60 L 16 110 L 14 150"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 88 60 L 104 110 L 106 150"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Thighs */}
<path
d="M 36 185 L 30 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 50 185 L 50 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 70 185 L 70 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 84 185 L 90 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Belt line */}
<line
x1="32"
y1="155"
x2="88"
y2="155"
stroke="currentColor"
strokeWidth="0.8"
strokeDasharray="2 3"
/>
{/* Navel reference */}
<circle cx="60" cy="130" r="1.5" fill="currentColor" />
{/* Click targets */}
{INJECTION_SITE_KEYS.map((site) => {
const coord = SITE_COORDS[site];
const isActive = site === value;
const isRecommended = site === recommended;
return (
<g key={site}>
{isRecommended && !isActive && (
<circle
cx={coord.x}
cy={coord.y}
r="9"
fill="none"
className="stroke-primary"
strokeWidth="1.5"
strokeDasharray="2 2"
/>
)}
<circle
cx={coord.x}
cy={coord.y}
r="6"
className={
isActive
? "fill-primary stroke-primary-foreground"
: "fill-muted stroke-foreground/60 hover:fill-accent"
}
strokeWidth="1.5"
/>
{/* Invisible 24-unit hit-target for touch. The SVG renders
at ≈1.07× scale (320px box / 300-unit viewBox height),
so r=12 gives ≈25.6 CSS px diameter — clearing the
WCAG 2.5.8 Level AA 24×24 floor. Going larger would
overlap the abdomen-left / abdomen-right pair at
Δx=24 units; the picker stays interactive without
spacing collisions. */}
<circle
cx={coord.x}
cy={coord.y}
r="12"
fill="transparent"
role="button"
tabIndex={0}
aria-pressed={isActive}
aria-label={t(describeInjectionSite(site))}
onClick={() => onChange(site)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(site);
}
}}
className="cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
/>
</g>
);
})}
</svg>
<div className="text-center text-xs">
{value ? (
<p className="text-foreground">{t(describeInjectionSite(value))}</p>
) : (
<p className="text-muted-foreground">
{recommended &&
t("medications.glp1RotationSuggested", {
site: t(describeInjectionSite(recommended)),
})}
</p>
)}
</div>
</div>
);
}
@@ -1,159 +0,0 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Check, SkipForward, Loader2, X } from "lucide-react";
import { formatDateWithWeekday, formatTime } 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 IntakeTimelineProps {
medicationId: string;
medicationName: string;
}
export function IntakeTimeline({
medicationId,
medicationName,
}: IntakeTimelineProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
const { data: events, isLoading } = useQuery({
queryKey: ["medications", medicationId, "intake"],
queryFn: async () => {
const res = await fetch(
`/api/medications/${medicationId}/intake?limit=50`,
);
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data.events as IntakeEvent[];
},
});
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);
},
});
if (isLoading) {
return (
<div className="flex h-20 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
);
}
if (!events?.length) {
return (
<p className="text-muted-foreground py-4 text-center text-sm">
{`${t("medications.noIntakesForMedication")} ${medicationName}`}
</p>
);
}
// Group by day
const grouped = new Map<string, IntakeEvent[]>();
for (const event of events) {
const day = formatDateWithWeekday(event.scheduledFor);
const list = grouped.get(day) ?? [];
list.push(event);
grouped.set(day, list);
}
return (
<div className="space-y-2.5">
{[...grouped.entries()].map(([day, dayEvents]) => (
<div key={day} className="space-y-1.5">
<p className="text-muted-foreground text-xs font-medium">{day}</p>
<div className="flex flex-wrap gap-1.5">
{dayEvents.map((event) => (
<div key={event.id} className="group relative inline-flex">
<Badge
variant={event.skipped ? "outline" : "secondary"}
className={
event.skipped
? "text-muted-foreground gap-1 pr-6"
: "gap-1 bg-green-500/20 pr-6 text-green-400"
}
>
{event.skipped ? (
<SkipForward className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
{formatTime(event.scheduledFor)}
{event.source !== "WEB" && (
<span className="text-[10px] opacity-60">
{event.source}
</span>
)}
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="text-destructive absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={t("medications.deleteIntakeAriaLabel")}
>
<X className="h-3 w-3" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.intakeDeleteConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.intakeDeleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate(event.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,509 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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);
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">
<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 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" />
<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
size="sm"
variant="outline"
className="h-7 px-2 text-[10px]"
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
size="sm"
variant="ghost"
className="h-7 px-2 text-[10px]"
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">
{t("medications.inventory.pastTitle", {
count: pastItems.length,
})}
</summary>
<ul 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
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
aria-label={t("medications.inventory.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</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>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("medications.inventory.addPenTitle")}
</DialogTitle>
</DialogHeader>
<form 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"
min={1}
max={100}
step={1}
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
required
/>
<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"
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"
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}
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 className="text-destructive text-sm">{formError}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
resetAddForm();
setAddOpen(false);
}}
>
{t("medications.inventory.cancel")}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("medications.inventory.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
@@ -35,6 +35,14 @@ interface Medication {
name: string;
dose: string;
category: string;
/**
* v1.4.25 W4d — Prisma treatment-class discriminator. When set to
* "GLP1" the parent should render the {@link Glp1MedicationCard}
* variant instead of this generic card. The field is optional for
* backwards compatibility with mocks.
*/
treatmentClass?: string;
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
@@ -0,0 +1,73 @@
"use client";
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).
*
* This wrapper locks the contract:
* - `border-border/60 rounded-md border` chrome
* - `px-3 py-2.5` header row with `text-foreground/85 text-sm font-medium` 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`
*
* Consumers pass:
* - `titleId` — the DOM id on the heading element. Components owning
* a heading id already (e.g. "titration-heading") pass it through;
* new surfaces should follow the `<slot>-heading` convention.
* - `title` — the rendered heading text (already i18n-translated by
* the caller).
* - `headerExtras` — optional right-side header content (e.g. drug
* INN badge, edit CTA, "Add" button).
* - `dataSlot` — optional `data-slot` attribute on the outer section
* so cross-section selectors (Playwright, snapshot tests) stay
* stable.
* - `bodyPaddingY` — escape hatch for sections that need a different
* vertical padding (the side-effects body runs at py-2.5 because
* it owns an inline timeline with its own gap rhythm).
*/
export interface MedicationDetailSectionProps {
titleId: string;
title: ReactNode;
headerExtras?: ReactNode;
children: ReactNode;
dataSlot?: string;
bodyPaddingY?: "py-2.5" | "py-3";
}
export function MedicationDetailSection({
titleId,
title,
headerExtras,
children,
dataSlot,
bodyPaddingY = "py-3",
}: MedicationDetailSectionProps) {
return (
<section
className="border-border/60 rounded-md border"
aria-labelledby={titleId}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<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"
>
{title}
</h2>
{headerExtras}
</header>
<div
className={`border-border/60 border-t px-3 text-xs ${bodyPaddingY}`}
>
{children}
</div>
</section>
);
}
+174 -8
View File
@@ -49,6 +49,7 @@ import {
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { toast } from "sonner";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
@@ -131,7 +132,7 @@ function formatNextWindowSummary(
schedule: Schedule,
t: TranslateFn,
formatShortDate: (date: Date) => string,
locale: "de" | "en",
locale: Locale,
): string {
const isSimpleDaily =
schedule.daysOfWeek.length === 0 && schedule.intervalWeeks === 1;
@@ -168,6 +169,10 @@ interface MedicationFormProps {
name: string;
dose: string;
category: string;
/** v1.4.25 W4d — Prisma treatment class. Defaults to GENERIC. */
treatmentClass?: string;
/** v1.4.25 W4d — only relevant for inventory-tracked meds. */
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled?: boolean;
schedules: Array<{
@@ -257,6 +262,16 @@ export function MedicationForm({
const [doseUnit, setDoseUnit] = useState(initialDose.unit);
const [category, setCategory] = useState(initial?.category ?? "OTHER");
// v1.4.25 W4d — treatment class. GLP1 unlocks the weekly-cadence
// preset below and the GLP-1 medication-card variant on the medications
// page. The default GENERIC keeps every existing medication rendering
// exactly as before.
const [treatmentClass, setTreatmentClass] = useState(
initial?.treatmentClass ?? "GENERIC",
);
const [dosesPerUnit, setDosesPerUnit] = useState<string>(
initial?.dosesPerUnit ? String(initial.dosesPerUnit) : "",
);
const [active, setActive] = useState(initial?.active ?? true);
const [notificationsEnabled, setNotificationsEnabled] = useState(
initial?.notificationsEnabled ?? true,
@@ -341,12 +356,61 @@ export function MedicationForm({
setDoseAmount("");
setDoseUnit("");
setCategory("OTHER");
setTreatmentClass("GENERIC");
setDosesPerUnit("");
setActive(true);
setNotificationsEnabled(true);
setSchedules([{ ...DEFAULT_SCHEDULE }]);
setError(null);
}
/**
* v1.4.25 W4d — "Once weekly on …" preset. Marc directive
* 2026-05-14: GLP-1 receptor agonists are weekly meds, and the
* existing form buries that behind two advanced toggles. The preset
* collapses the picker to a single weekday button row that writes
* MedicationSchedule.daysOfWeek correctly and remembers the choice
* locally so the next weekly med starts on the same weekday.
*
* Persisted to localStorage as a one-row preference so the convenience
* survives across sessions — non-invasive (key is namespaced) and
* web-only (the iOS surface uses its own picker).
*/
function applyWeeklyPreset(weekday: number) {
try {
localStorage.setItem(
"medication-form:last-weekly-weekday",
String(weekday),
);
} catch {
/* private mode — silent */
}
setSchedules((prev) =>
sortSchedules(
prev.map((s, i) =>
i === 0
? {
...s,
daysOfWeek: [weekday],
intervalWeeks: 1,
showAdvanced: true,
}
: s,
),
),
);
}
function readLastWeeklyWeekday(): number {
try {
const raw = localStorage.getItem("medication-form:last-weekly-weekday");
const n = raw === null ? NaN : Number(raw);
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : 3; // Wednesday default
} catch {
return 3;
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
@@ -363,6 +427,12 @@ export function MedicationForm({
name,
dose,
category,
treatmentClass,
...(dosesPerUnit
? { dosesPerUnit: Number(dosesPerUnit) }
: isEdit
? { dosesPerUnit: null }
: {}),
...(isEdit
? {
active,
@@ -504,6 +574,33 @@ export function MedicationForm({
</Select>
</div>
{/* v1.4.25 W4d — treatment-class selector. Surfaces the GLP-1
option without crowding the existing clinical-category
dropdown (those are orthogonal taxonomies). GENERIC keeps the
form rendering exactly as v1.4.24; GLP1 opens the
weekly-cadence preset + pen-inventory inputs below. */}
<div className="space-y-1.5">
<Label htmlFor="med-treatment-class">
{t("medications.formTreatmentClass")}
</Label>
<Select value={treatmentClass} onValueChange={setTreatmentClass}>
<SelectTrigger id="med-treatment-class" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GENERIC">
{t("medications.treatmentClassGeneric")}
</SelectItem>
<SelectItem value="GLP1">
{t("medications.treatmentClassGlp1")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.formTreatmentClassHelp")}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="med-dose-amount">{t("medications.formDose")}</Label>
@@ -537,6 +634,75 @@ export function MedicationForm({
))}
</datalist>
{treatmentClass === "GLP1" && (
<>
{/* v1.4.25 W4d — once-weekly preset. One tap on a weekday
writes daysOfWeek=[N] + intervalWeeks=1 into the first
schedule and reveals the advanced section so the user can
still tune the window. Persists the last pick across
sessions for convenience. */}
<div className="border-border/70 bg-card/60 space-y-2.5 rounded-xl border p-3.5">
<div className="space-y-1">
<Label className="text-sm leading-none">
{t("medications.glp1WeeklyPresetTitle")}
</Label>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.glp1WeeklyPresetHelp")}
</p>
</div>
<div className="flex w-full gap-1">
{getDayLabelsShort(t).map((label, dayIndex) => {
const isSelected =
schedules[0]?.daysOfWeek.length === 1 &&
schedules[0]?.daysOfWeek[0] === dayIndex;
return (
<button
key={dayIndex}
type="button"
onClick={() => applyWeeklyPreset(dayIndex)}
className={`h-9 flex-1 rounded-md border text-xs font-medium transition-colors ${
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isSelected}
>
{label}
</button>
);
})}
</div>
{!schedules.some((s) => s.daysOfWeek.length === 1) && (
<button
type="button"
onClick={() => applyWeeklyPreset(readLastWeeklyWeekday())}
className="text-primary text-xs underline-offset-2 hover:underline"
>
{t("medications.glp1WeeklyPresetSuggest")}
</button>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="med-doses-per-unit">
{t("medications.glp1DosesPerUnit")}
</Label>
<Input
id="med-doses-per-unit"
type="number"
inputMode="numeric"
min={1}
max={100}
value={dosesPerUnit}
onChange={(e) => setDosesPerUnit(e.target.value)}
placeholder={t("medications.glp1DosesPerUnitPlaceholder")}
/>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.glp1DosesPerUnitHelp")}
</p>
</div>
</>
)}
<div className="space-y-2">
<div className="flex h-8 items-center justify-between">
<Label className="text-sm leading-none">
@@ -546,7 +712,7 @@ export function MedicationForm({
type="button"
variant="ghost"
size="sm"
className="h-8"
className="min-h-11"
onClick={addSchedule}
>
<Plus className="mr-1 h-3.5 w-3.5" />
@@ -575,7 +741,7 @@ export function MedicationForm({
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
@@ -612,7 +778,7 @@ export function MedicationForm({
pattern="[0-2][0-9]:[0-5][0-9]"
placeholder="08:00"
value={s.windowStart}
className="h-8 text-xs md:text-xs"
className="h-11 text-xs md:text-xs"
onChange={(e) =>
updateSchedule(i, "windowStart", e.target.value)
}
@@ -630,7 +796,7 @@ export function MedicationForm({
pattern="[0-2][0-9]:[0-5][0-9]"
placeholder="09:00"
value={s.windowEnd}
className="h-8 text-xs md:text-xs"
className="h-11 text-xs md:text-xs"
onChange={(e) =>
updateSchedule(i, "windowEnd", e.target.value)
}
@@ -644,7 +810,7 @@ export function MedicationForm({
</Label>
<Input
value={s.label}
className="h-8 text-xs md:text-xs"
className="h-11 text-xs md:text-xs"
onChange={(e) => updateSchedule(i, "label", e.target.value)}
placeholder={t("medications.labelPlaceholder")}
maxLength={50}
@@ -656,7 +822,7 @@ export function MedicationForm({
</Label>
<Input
value={s.dose}
className="h-8 text-xs md:text-xs"
className="h-11 text-xs md:text-xs"
onChange={(e) => updateSchedule(i, "dose", e.target.value)}
placeholder={dose || t("medications.defaultDose")}
maxLength={50}
@@ -684,7 +850,7 @@ export function MedicationForm({
onClick={() =>
updateSchedule(i, "intervalWeeks", weeks)
}
className={`h-8 rounded-md border text-xs font-medium transition-colors ${
className={`min-h-11 rounded-md border text-xs font-medium transition-colors ${
s.intervalWeeks === weeks
? "border-primary bg-primary text-primary-foreground"
: "border-border/70 bg-muted text-foreground/70 hover:bg-accent hover:text-foreground"
+65
View File
@@ -25,6 +25,24 @@ const MOOD_LEVELS = [
{ value: "LAUSIG", score: 1, labelKey: "mood.levelLausig" },
] as const;
/**
* v1.4.25 W4d — curated GLP-1 side-effect chip strip. Tapping a chip
* appends the localised tag string to the existing free-text tag input
* (comma-separated). The list is intentionally short — these are the
* symptoms clinicians most often ask about at GLP-1 follow-up visits
* (cf. PMC GLP-1 adverse-effects review). Generic mood entries that
* never touch the chips keep their tag input byte-identical to v1.4.24.
*/
const GLP1_SIDE_EFFECT_KEYS = [
"medications.sideEffectTagNausea",
"medications.sideEffectTagConstipation",
"medications.sideEffectTagDiarrhea",
"medications.sideEffectTagFatigue",
"medications.sideEffectTagAppetiteLoss",
"medications.sideEffectTagHeartburn",
"medications.sideEffectTagHeadache",
] as const;
interface MoodFormProps {
onSuccess?: () => void;
onCancel?: () => void;
@@ -159,6 +177,53 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
onChange={(e) => setTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
/>
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
appends the localised label to the free-text tag list.
Always visible for now (cheap UX; the Coach side-effect
aggregator filters on the canonical English tag set so the
German labels still register correctly). */}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
const label = t(key);
const tags = tagsInput
.split(",")
.map((p) => p.trim().toLowerCase());
const isActive = tags.includes(label.toLowerCase());
return (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter((p) => p.toLowerCase() !== label.toLowerCase());
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`min-h-8 rounded-full border px-2.5 py-1 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
</div>
</div>
</div>
{error && (
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
/**
* v1.4.25 W14b-Content — onboarding step 3 (baseline).
*
* Captures the four profile fields the original v1.4.20 wizard
* collected on its "About you" screen — display name, height, date of
* birth, gender — but spread across a single mobile-friendly card
* instead of the older grid layout. The legacy `/api/onboarding/complete`
* endpoint is *not* used here: completion now flips on the new
* `POST /api/onboarding/step` with `{ step: 4 }`. Profile fields are
* persisted via `PUT /api/auth/profile`, the existing canonical write
* path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`).
*
* Submit flow on "Save and continue":
* 1. PUT profile (best-effort — empty fields are skipped).
* 2. POST step:4 — flips `onboardingCompletedAt` server-side and
* clears the proxy cookie.
* 3. router.push("/onboarding/4") — the done screen.
*
* "Skip" advances without writing profile data; the wizard still
* completes onboarding (the user can fill profile later from
* Settings).
*/
interface BaselineFormState {
displayName: string;
heightCm: string;
dateOfBirth: string;
gender: string;
}
const EMPTY_FORM: BaselineFormState = {
displayName: "",
heightCm: "",
dateOfBirth: "",
gender: "",
};
export function BaselineForm() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
function patch<K extends keyof BaselineFormState>(
key: K,
value: BaselineFormState[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function advance(opts: { saveProfile: boolean }) {
if (saving) return;
setSaving(true);
try {
if (opts.saveProfile) {
const profileBody: Record<string, unknown> = {};
if (form.displayName.trim())
profileBody.displayName = form.displayName.trim();
if (form.heightCm) {
const n = Number.parseFloat(form.heightCm);
if (Number.isFinite(n)) profileBody.heightCm = n;
}
if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
if (form.gender === "MALE" || form.gender === "FEMALE") {
profileBody.gender = form.gender;
}
if (Object.keys(profileBody).length > 0) {
const profileRes = await fetch("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profileBody),
});
if (!profileRes.ok) throw new Error(await readError(profileRes));
}
}
const stepRes = await fetch("/api/onboarding/step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 4 }),
});
if (!stepRes.ok) throw new Error(await readError(stepRes));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/4");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
setSaving(false);
}
}
return (
<section
aria-labelledby="onboarding-baseline-title"
className="space-y-6"
>
<header className="space-y-2">
<h1
id="onboarding-baseline-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.baseline.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.baseline.body")}
</p>
</header>
<fieldset className="bg-card border-border space-y-5 rounded-xl border p-5">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<div className="space-y-2">
<Label htmlFor="ob-baseline-display-name">
{t("onboarding.baseline.displayNameLabel")}
</Label>
<Input
id="ob-baseline-display-name"
value={form.displayName}
onChange={(e) => patch("displayName", e.target.value)}
autoComplete="nickname"
maxLength={50}
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.baseline.displayNameHint")}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ob-baseline-height">
{t("onboarding.baseline.heightLabel")}
</Label>
<Input
id="ob-baseline-height"
type="number"
inputMode="decimal"
value={form.heightCm}
onChange={(e) => patch("heightCm", e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-baseline-gender">
{t("onboarding.baseline.genderLabel")}
</Label>
<Select
// The design system's Radix Select uses an empty-string
// sentinel to mean "no selection"; map back and forth so
// the form state ("") and the Select's value (undefined-
// adjacent) stay aligned. v1.4.25 W21 Fix-N (design-M1).
value={form.gender === "" ? undefined : form.gender}
onValueChange={(next) => patch("gender", next)}
>
<SelectTrigger
id="ob-baseline-gender"
className="w-full"
data-slot="onboarding-baseline-gender"
>
<SelectValue
placeholder={t("onboarding.baseline.genderNone")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="MALE">
{t("onboarding.baseline.genderMale")}
</SelectItem>
<SelectItem value="FEMALE">
{t("onboarding.baseline.genderFemale")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ob-baseline-dob">
{t("onboarding.baseline.dateOfBirthLabel")}
</Label>
<DateInput
id="ob-baseline-dob"
value={form.dateOfBirth}
onChange={(e) => patch("dateOfBirth", e.target.value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.baseline.dateOfBirthHint")}
</p>
</div>
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/2">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => advance({ saveProfile: false })}
disabled={saving}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={() => advance({ saveProfile: true })}
disabled={saving}
className="min-h-11 min-w-11"
>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{t("onboarding.baseline.saveCta")}
</Button>
</div>
</div>
</section>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W14b-Content — onboarding step 4 (done).
*
* Confirms the user has finished onboarding. `onboardingCompletedAt`
* was flipped server-side by the baseline form's `POST step:4` write,
* so this page is purely presentational — no further mutation, no
* cookie flips, no API calls.
*
* The single CTA returns the user to the dashboard. The link goes
* through `next/link` so the regular client-side navigation kicks in
* (the proxy redirect was cleared with the same step-API write that
* landed the user here).
*/
export function DoneScreen() {
const { t } = useTranslations();
return (
<section
aria-labelledby="onboarding-done-title"
className="flex flex-col items-center gap-6 py-6 text-center"
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-20 items-center justify-center rounded-full"
>
<CheckCircle2 className="size-10" />
</span>
<header className="space-y-2">
<h1
id="onboarding-done-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.done.title")}
</h1>
<p className="text-muted-foreground mx-auto max-w-md text-base leading-relaxed">
{t("onboarding.done.body")}
</p>
</header>
<Button asChild size="lg">
<Link href="/">{t("onboarding.done.returnCta")}</Link>
</Button>
</section>
);
}
@@ -0,0 +1,233 @@
"use client";
import { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Brain,
Droplet,
HeartPulse,
Moon,
Pill,
Scale,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
/**
* v1.4.25 W14b-Content — onboarding step 1 (goals).
*
* Multi-select chip picker for the metrics the user wants to track.
* The goal slugs match a stable enum that v1.4.26 will land on the
* `User` row (`User.onboardingGoals`). Until that column ships the
* picker holds selection in component state only — there's no
* persistence layer to drop into. The earlier localStorage block
* had no consumer (no other surface reads the same key) and the
* single-step wizard makes pre-selection on resume unnecessary, so
* leaning on component state until the column lands keeps the code
* surface honest.
*
* CTAs:
* - Back → `/onboarding/0`
* - Skip → advance with empty goal set
* - Next → advance with chosen goal set
*
* Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }`
* and `router.push("/onboarding/2")`.
*/
export type OnboardingGoalSlug =
| "weight-management"
| "bp-tracking"
| "glucose-tracking"
| "sleep-improvement"
| "medication-compliance"
| "general-wellness";
interface GoalOption {
slug: OnboardingGoalSlug;
Icon: LucideIcon;
labelKey: string;
}
const GOAL_OPTIONS: ReadonlyArray<GoalOption> = [
{
slug: "weight-management",
Icon: Scale,
labelKey: "onboarding.goals.options.weightManagement",
},
{
slug: "bp-tracking",
Icon: HeartPulse,
labelKey: "onboarding.goals.options.bpTracking",
},
{
slug: "glucose-tracking",
Icon: Droplet,
labelKey: "onboarding.goals.options.glucoseTracking",
},
{
slug: "sleep-improvement",
Icon: Moon,
labelKey: "onboarding.goals.options.sleepImprovement",
},
{
slug: "medication-compliance",
Icon: Pill,
labelKey: "onboarding.goals.options.medicationCompliance",
},
{
slug: "general-wellness",
Icon: Brain,
labelKey: "onboarding.goals.options.generalWellness",
},
];
export interface GoalsChipPickerProps {
/**
* The current user's id. Threaded in from the server step page
* (`getSession().user.id`) so the v1.4.26 column write can reuse
* the same call site without a prop reshuffle.
*/
userId: string;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<OnboardingGoalSlug>>(
() => new Set(),
);
const [advancing, setAdvancing] = useState(false);
const toggle = useCallback((slug: OnboardingGoalSlug) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
return next;
});
}, []);
async function advance() {
if (advancing) return;
setAdvancing(true);
try {
const res = await fetch("/api/onboarding/step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 2 }),
});
if (!res.ok) throw new Error(await readError(res));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/2");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
setAdvancing(false);
}
}
return (
<section
aria-labelledby="onboarding-goals-title"
className="space-y-6"
>
<header className="space-y-2">
<h1
id="onboarding-goals-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.goals.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.goals.body")}
</p>
<p className="text-muted-foreground text-xs">
{t("onboarding.goals.helpHint")}
</p>
</header>
<fieldset
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
aria-describedby="onboarding-goals-title"
>
<legend className="sr-only">{t("onboarding.goals.title")}</legend>
{GOAL_OPTIONS.map((option) => {
const checked = selected.has(option.slug);
const Icon = option.Icon;
return (
<label
key={option.slug}
className={cn(
"border-border bg-card flex min-h-[88px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border p-3 text-center transition-colors duration-150 ease-out motion-reduce:transition-none",
"hover:border-primary/50",
checked && "border-primary bg-primary/5",
)}
>
<input
type="checkbox"
name="onboarding-goal"
value={option.slug}
checked={checked}
onChange={() => toggle(option.slug)}
className="sr-only"
/>
<span
aria-hidden="true"
className={cn(
"flex size-9 items-center justify-center rounded-full transition-colors",
checked
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-4" />
</span>
<span className="text-foreground text-sm font-medium leading-tight">
{t(option.labelKey)}
</span>
</label>
);
})}
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/0">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.next")}
</Button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,168 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Button } from "@/components/ui/button";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
/**
* v1.4.25 W14b — onboarding wizard chrome.
*
* Renders the shared frame around every step in the rebuilt
* `/onboarding/[step]` flow: logo, "Step N of 4" label, four-dot
* progress strip, the step body slot, and the back / skip / next
* action row pinned to the bottom safe-area.
*
* Step encoding (mirrors `User.onboardingStep`):
* 0 = welcome (no progress dot; intro screen, primary action is "next")
* 1 = goals (dot 1 active)
* 2 = source (dot 2 active)
* 3 = baseline (dot 3 active)
* 4 = done (dot 4 active; "done" copy; primary CTA = return to dashboard)
*
* Server component — every step page is also a server component and
* the shell carries no client state. Back / skip / next are plain
* `<Link>`s wrapping `<Button>`s; the W14b-Content agent will replace
* those with form submissions / mutations on the step pages that need
* them, while the chrome itself stays static.
*
* Locale resolution mirrors the rest of the server tree
* (`resolveServerLocale` → cookie → User.locale → Accept-Language).
*/
export interface OnboardingShellProps {
/** 0..4 — see step encoding above. Out-of-range values clamp to 0. */
step: number;
/**
* Step body slot. Step pages render their step-specific UI here; the
* shell handles every surrounding pixel.
*/
children: React.ReactNode;
/** Href for the back button. Hidden when undefined (e.g. step 0). */
backHref?: string;
/** Href for the "Skip" link. Hidden when undefined. */
skipHref?: string;
/** Href for the primary forward CTA. Hidden when undefined. */
nextHref?: string;
/**
* Override the primary CTA label. Defaults to `onboarding.shell.next`
* (or `onboarding.shell.done.returnCta` at step 4).
*/
nextLabel?: string;
/** Override the userLocale used for translator resolution (test hook). */
userLocale?: string | null;
className?: string;
}
const TOTAL_STEPS = 4;
export async function OnboardingShell({
step,
children,
backHref,
skipHref,
nextHref,
nextLabel,
userLocale,
className,
}: OnboardingShellProps) {
const clamped = clampStep(step);
const locale = await resolveServerLocale({ userLocale: userLocale ?? null });
const { t } = getServerTranslator(locale);
// Progress label only appears for the four real steps; the welcome
// intro (step 0) and the done screen (step 4) both reuse the
// ordinal keys (1/4 and 4/4 respectively) so the strip never goes
// blank between transitions.
const progressLabel =
clamped === 0
? t("onboarding.shell.step1of4")
: t(`onboarding.shell.step${clamped}of4`);
const primaryLabel =
nextLabel ?? (clamped === 4 ? t("onboarding.done.returnCta") : t("onboarding.shell.next"));
return (
<div
className={cn(
"mx-auto flex min-h-[100svh] w-full max-w-xl flex-col",
// Safe-area-respecting bottom padding for iOS PWA standalone.
// Mirrors the pattern called out in the W14b research file
// (Section 4.4) — the home-bar overlaps the primary CTA
// otherwise.
"px-4 pt-6 pb-[max(env(safe-area-inset-bottom),1rem)]",
className,
)}
>
<header className="mb-6 flex items-center justify-between gap-3">
<Logo size={32} />
<p
className="text-muted-foreground text-sm font-medium"
aria-live="polite"
>
{progressLabel}
</p>
</header>
<div
role="progressbar"
aria-valuemin={1}
aria-valuemax={TOTAL_STEPS}
aria-valuenow={clamped === 0 ? 1 : clamped}
aria-label={progressLabel}
className="mb-8 flex items-center gap-2"
>
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const dotStep = i + 1;
const active = clamped >= dotStep || (clamped === 0 && dotStep === 1);
return (
<span
key={dotStep}
data-active={active}
aria-current={
(clamped === 0 ? 1 : clamped) === dotStep ? "step" : undefined
}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
active ? "bg-primary" : "bg-muted",
)}
/>
);
})}
</div>
<main className="flex-1">{children}</main>
<footer className="mt-8 flex items-center justify-between gap-2">
{backHref ? (
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={backHref}>{t("onboarding.shell.back")}</Link>
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="flex items-center gap-2">
{skipHref ? (
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={skipHref}>{t("onboarding.shell.skip")}</Link>
</Button>
) : null}
{nextHref ? (
<Button asChild className="min-h-11 min-w-11">
<Link href={nextHref}>{primaryLabel}</Link>
</Button>
) : null}
</div>
</footer>
</div>
);
}
function clampStep(step: number): 0 | 1 | 2 | 3 | 4 {
if (!Number.isFinite(step)) return 0;
const rounded = Math.floor(step);
if (rounded < 0) return 0;
if (rounded > 4) return 4;
return rounded as 0 | 1 | 2 | 3 | 4;
}
@@ -0,0 +1,312 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Apple,
ClipboardCheck,
Watch,
Wifi,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
/**
* v1.4.25 W14b-Content — onboarding step 2 (source).
*
* Four-card grid presenting the data sources HealthLog supports today
* plus the announce-only Apple Health card so users land on v1.5 with
* the right expectation.
*
* Cards:
* 1. Manual entry (enabled, recommended)
* 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new
* tab; the popup hands control back via the existing callback)
* 3. Apple Health (DISABLED — "Coming with v1.5 iOS app")
* 4. Garmin — omitted entirely. Not on the v1.5 roadmap so showing
* a disabled card adds noise without conveying a real promise.
*
* Selection is non-binding — Manual is the implicit default and the
* Next CTA always advances. The Withings card opens the OAuth flow in
* a new tab; the user returns to this step to click Next when they're
* back (callback handler flips the connection server-side, no client
* polling needed for the wizard contract).
*/
interface SourceCard {
slug: "manual" | "withings" | "apple-health";
Icon: LucideIcon;
titleKey: string;
bodyKey: string;
/**
* One of:
* - "enabled-select": tap selects the card (Manual)
* - "enabled-oauth": tap opens OAuth in a new tab (Withings)
* - "disabled": semi-transparent, badge, no-op (Apple Health)
*/
state: "enabled-select" | "enabled-oauth" | "disabled";
/** External href for `enabled-oauth`. */
href?: string;
/** Optional badge under the title (e.g. "Coming with v1.5"). */
badgeKey?: string;
/** Optional recommended badge ("Most popular"). */
recommendedKey?: string;
}
const SOURCE_CARDS: ReadonlyArray<SourceCard> = [
{
slug: "manual",
Icon: ClipboardCheck,
titleKey: "onboarding.source.manual.title",
bodyKey: "onboarding.source.manual.body",
state: "enabled-select",
recommendedKey: "onboarding.source.recommended",
},
{
slug: "withings",
Icon: Watch,
titleKey: "onboarding.source.withings.title",
bodyKey: "onboarding.source.withings.body",
state: "enabled-oauth",
href: "/api/withings/connect",
},
{
slug: "apple-health",
Icon: Apple,
titleKey: "onboarding.source.appleHealth.title",
bodyKey: "onboarding.source.appleHealth.body",
state: "disabled",
badgeKey: "onboarding.source.appleHealth.badge",
},
];
export function SourceCardGrid() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<SourceCard["slug"]>("manual");
const [advancing, setAdvancing] = useState(false);
async function advance() {
if (advancing) return;
setAdvancing(true);
try {
const res = await fetch("/api/onboarding/step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 3 }),
});
if (!res.ok) throw new Error(await readError(res));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/3");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
setAdvancing(false);
}
}
return (
<section
aria-labelledby="onboarding-source-title"
className="space-y-6"
>
<header className="space-y-2">
<h1
id="onboarding-source-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.source.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.source.body")}
</p>
</header>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{SOURCE_CARDS.map((card) => (
<SourceCardItem
key={card.slug}
card={card}
selected={selected === card.slug}
onSelect={() => setSelected(card.slug)}
titleLabel={t(card.titleKey)}
bodyLabel={t(card.bodyKey)}
recommendedLabel={
card.recommendedKey ? t(card.recommendedKey) : null
}
badgeLabel={card.badgeKey ? t(card.badgeKey) : null}
withingsCtaLabel={t("onboarding.source.withings.cta")}
disabledHintLabel={t("onboarding.source.appleHealth.disabledHint")}
/>
))}
</div>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/1">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.next")}
</Button>
</div>
</div>
</section>
);
}
interface SourceCardItemProps {
card: SourceCard;
selected: boolean;
onSelect: () => void;
titleLabel: string;
bodyLabel: string;
recommendedLabel: string | null;
badgeLabel: string | null;
withingsCtaLabel: string;
disabledHintLabel: string;
}
function SourceCardItem({
card,
selected,
onSelect,
titleLabel,
bodyLabel,
recommendedLabel,
badgeLabel,
withingsCtaLabel,
disabledHintLabel,
}: SourceCardItemProps) {
const Icon = card.Icon;
const baseClasses = cn(
"relative flex flex-col gap-3 rounded-lg border p-4 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
"bg-card border-border",
);
const headerRow = (
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-md",
selected && card.state !== "disabled"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
{badgeLabel ? (
<span
className="bg-muted text-muted-foreground mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
data-testid={`source-badge-${card.slug}`}
>
{badgeLabel}
</span>
) : recommendedLabel ? (
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide">
{recommendedLabel}
</span>
) : null}
</div>
</div>
);
const body = (
<p className="text-muted-foreground text-sm leading-relaxed">{bodyLabel}</p>
);
if (card.state === "disabled") {
return (
<div
aria-disabled="true"
className={cn(baseClasses, "cursor-not-allowed opacity-60")}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
<p className="text-muted-foreground text-xs italic">
{disabledHintLabel}
</p>
</div>
);
}
if (card.state === "enabled-oauth") {
return (
<div
className={cn(
baseClasses,
selected && "border-primary bg-primary/5",
"hover:border-primary/50 focus-within:border-primary/50",
)}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
<div className="flex items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm">
<a
href={card.href}
target="_blank"
rel="noopener"
onClick={onSelect}
className="inline-flex items-center gap-1.5"
>
<Wifi className="size-3.5" />
{withingsCtaLabel}
</a>
</Button>
</div>
</div>
);
}
// enabled-select (Manual)
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={cn(
baseClasses,
"cursor-pointer hover:border-primary/50",
selected && "border-primary bg-primary/5",
)}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
</button>
);
}
@@ -0,0 +1,297 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
HeartPulse,
Plug,
Sparkles,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
/**
* v1.4.25 W14b-Content — onboarding welcome carousel.
*
* Three value-prop slides shown on `/onboarding/0` before any data is
* asked of the user. Mirrors the Withings / Strava pattern surfaced in
* the W14b research file (§2.1, §2.5): pitch the product, then ask.
*
* Slides:
* 1. Tagline — "Your health data, owned by you."
* 2. Multi-source — Apple Health + Withings + manual, one view.
* 3. AI Coach + Insights — evidence-grounded, your data only.
*
* The carousel is a CSS scroll-snap rail; arrow buttons + dot pager
* mirror the active slide. `prefers-reduced-motion` disables the smooth
* scroll behaviour (per WCAG 2.3.3 / research §4.6).
*
* The primary CTA ("Get started") POSTs `/api/onboarding/step` with
* `{ step: 1 }`. On success it invalidates the `auth` query (so the
* user-shape's `onboardingStep` re-reads) and pushes to `/onboarding/1`.
*/
interface CarouselSlide {
id: "tagline" | "sources" | "coach";
Icon: LucideIcon;
titleKey: string;
bodyKey: string;
}
const SLIDES: ReadonlyArray<CarouselSlide> = [
{
id: "tagline",
Icon: HeartPulse,
titleKey: "onboarding.welcome.slide1.title",
bodyKey: "onboarding.welcome.slide1.body",
},
{
id: "sources",
Icon: Plug,
titleKey: "onboarding.welcome.slide2.title",
bodyKey: "onboarding.welcome.slide2.body",
},
{
id: "coach",
Icon: Sparkles,
titleKey: "onboarding.welcome.slide3.title",
bodyKey: "onboarding.welcome.slide3.body",
},
];
export function WelcomeCarousel() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const railRef = useRef<HTMLDivElement | null>(null);
const liveRegionId = useId();
const [active, setActive] = useState(0);
const [advancing, setAdvancing] = useState(false);
// Track which slide is centred in the rail. We observe each slide
// with an IntersectionObserver — simpler and more reliable than
// computing scroll-positions, and it handles touch flings naturally.
useEffect(() => {
const rail = railRef.current;
if (!rail) return;
const slides = Array.from(
rail.querySelectorAll<HTMLElement>("[data-slide-index]"),
);
if (slides.length === 0) return;
const io = new IntersectionObserver(
(entries) => {
// Pick the entry whose intersection ratio is highest — that's
// the slide currently snapped into view.
let best: { idx: number; ratio: number } | null = null;
for (const e of entries) {
const idx = Number((e.target as HTMLElement).dataset.slideIndex);
if (!Number.isFinite(idx)) continue;
if (!best || e.intersectionRatio > best.ratio) {
best = { idx, ratio: e.intersectionRatio };
}
}
if (best && best.ratio > 0.5) {
setActive(best.idx);
}
},
{ root: rail, threshold: [0.5, 0.75, 1] },
);
for (const s of slides) io.observe(s);
return () => io.disconnect();
}, []);
const scrollToSlide = useCallback((idx: number) => {
const rail = railRef.current;
if (!rail) return;
const target = rail.querySelector<HTMLElement>(
`[data-slide-index="${idx}"]`,
);
if (!target) return;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
target.scrollIntoView({
behavior: reduceMotion ? "auto" : "smooth",
inline: "start",
block: "nearest",
});
}, []);
async function handleGetStarted() {
if (advancing) return;
setAdvancing(true);
try {
const res = await fetch("/api/onboarding/step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 1 }),
});
if (!res.ok) throw new Error(await readError(res));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/1");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
setAdvancing(false);
}
}
return (
<section
aria-labelledby="onboarding-welcome-title"
className="space-y-6"
>
<h1
id="onboarding-welcome-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.welcome.title")}
</h1>
<div className="relative">
<div
ref={railRef}
role="region"
aria-roledescription="carousel"
aria-label={t("onboarding.welcome.carouselLabel")}
className={cn(
"flex w-full snap-x snap-mandatory gap-4 overflow-x-auto",
// Hide native scrollbar; the dot pager is the visible affordance.
"scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
"scroll-smooth motion-reduce:scroll-auto",
)}
>
{SLIDES.map((slide, idx) => (
<article
key={slide.id}
data-slide-index={idx}
role="group"
aria-roledescription="slide"
aria-labelledby={`onboarding-welcome-slide-${slide.id}-title`}
className={cn(
"bg-card border-border flex w-full shrink-0 snap-start flex-col items-center gap-4",
"rounded-xl border p-6 text-center sm:p-8",
)}
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-16 items-center justify-center rounded-full"
>
<slide.Icon className="size-8" />
</span>
<h2
id={`onboarding-welcome-slide-${slide.id}-title`}
className="text-lg font-semibold tracking-tight"
>
{t(slide.titleKey)}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
{t(slide.bodyKey)}
</p>
</article>
))}
</div>
{/* Single live region announces slide changes to AT users —
slides themselves carry `aria-label="slide N of total"` so a
second sr-only mirror would double-announce on every move.
The carousel-level region wins because it's tied to the
chevron + dot pager via `aria-controls`. */}
<p id={liveRegionId} aria-live="polite" className="sr-only">
{t("onboarding.welcome.slideOf", {
current: active + 1,
total: SLIDES.length,
})}
</p>
</div>
<div className="flex items-center justify-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => scrollToSlide(Math.max(0, active - 1))}
disabled={active === 0}
aria-label={t("onboarding.welcome.prevSlide")}
aria-controls={liveRegionId}
className="min-h-11 min-w-11"
>
<ChevronLeft className="size-4" />
</Button>
<div
role="tablist"
aria-label={t("onboarding.welcome.carouselLabel")}
className="flex items-center"
>
{SLIDES.map((slide, idx) => (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={active === idx}
aria-label={t("onboarding.welcome.gotoSlide", {
index: idx + 1,
})}
onClick={() => scrollToSlide(idx)}
className="inline-flex size-11 items-center justify-center"
>
<span
aria-hidden="true"
className={cn(
"size-2.5 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
active === idx
? "bg-primary"
: "bg-muted hover:bg-muted-foreground/30",
)}
/>
</button>
))}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
scrollToSlide(Math.min(SLIDES.length - 1, active + 1))
}
disabled={active === SLIDES.length - 1}
aria-label={t("onboarding.welcome.nextSlide")}
aria-controls={liveRegionId}
className="min-h-11 min-w-11"
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="flex justify-end pt-2">
<Button
type="button"
size="lg"
onClick={handleGetStarted}
disabled={advancing}
className="min-h-11"
>
{t("onboarding.welcome.cta")}
</Button>
</div>
</section>
);
}
@@ -0,0 +1,191 @@
/**
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
*
* The card lives next to the danger-zone delete and drives three
* server interactions:
*
* GET /api/auth/me/research-mode on mount, via TanStack Query
* POST /api/auth/me/research-mode via the acknowledgment dialog
* DELETE /api/auth/me/research-mode directly when the user
* toggles OFF
*
* The toggle ON path opens the dialog; only the dialog's CTA fires
* the POST (so the user reads the disclaimer first). OFF fires
* DELETE directly. A version-mismatch banner renders above the
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
* even when `enabled === true`.
*
* The dialog itself is covered by
* `ResearchModeAcknowledgmentDialog.test.tsx`; we mock it down to a
* marker here so the toggle behaviour is the only contract under
* test.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const queryResults: Record<string, unknown> = {};
function setQueryResult(keyJoined: string, data: unknown) {
queryResults[keyJoined] = data;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = (queryKey as Array<string | number>).join("/");
return {
data: queryResults[key],
isLoading: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// The acknowledgment dialog is exercised in its own spec; mock the
// import to a transparent marker so we can assert "the toggle ON path
// renders the dialog with the right props" without re-running the
// dialog tree.
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
ResearchModeAcknowledgmentDialog: ({
open,
currentDisclaimerVersion,
}: {
open: boolean;
currentDisclaimerVersion: string | null;
}) => (
<div
data-slot="mock-ack-dialog"
data-open={open ? "true" : "false"}
data-version={currentDisclaimerVersion ?? ""}
/>
),
}));
import { AdvancedSection } from "../advanced-section";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
beforeEach(() => {
for (const k of Object.keys(queryResults)) delete queryResults[k];
});
describe("<AdvancedSection> → Research Mode card", () => {
it("renders the section title + subtitle + toggle in OFF state", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
expect(html).toContain('data-slot="settings-research-mode-card"');
expect(html).toContain("Research Mode");
expect(html).toContain("Show the estimated drug-level chart");
// OFF state status line.
expect(html).toContain("Disabled. The drug-level chart is hidden.");
// No re-prompt banner when versions already align (or the user has
// never acknowledged in the first place).
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
// Switch is unchecked.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toBeDefined();
expect(switchTag).not.toMatch(/data-state="checked"/);
});
it("renders the acknowledged-on status when enabled + versions aligned", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T08:30:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
// Acknowledged-on copy carries the formatted date (the i18n
// template uses `{date}`; the formatter renders the day).
expect(html).toMatch(/Acknowledged on/);
// Banner stays hidden when versions match.
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
// Switch is checked.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toMatch(/data-state="checked"/);
});
it("renders the re-prompt banner when versions diverge", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-04-01T00:00:00Z",
acknowledgedVersion: "2026-04-01.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
expect(html).toContain("Disclaimer updated");
expect(html).toContain(
'data-slot="settings-research-mode-reprompt-cta"',
);
expect(html).toContain("Re-acknowledge disclaimer");
// Status line reflects the stale state without claiming
// "acknowledged on …" — that copy would mis-represent that the
// chart is currently painting.
expect(html).not.toMatch(/Acknowledged on/);
expect(html).toContain(
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
);
// The toggle stays "on" — we surface the previous choice; the
// banner explains why the chart isn't painting.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toMatch(/data-state="checked"/);
});
it("forwards the current server-supplied version to the dialog", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-06-01.2",
});
const html = render(<AdvancedSection />);
// The dialog renders even when closed (its `open` prop is what
// controls visibility); the data-version attribute lets us verify
// the parent passes the live version string through.
expect(html).toContain('data-slot="mock-ack-dialog"');
expect(html).toMatch(/data-version="2026-06-01\.2"/);
expect(html).toMatch(/data-open="false"/);
});
it("renders German copy when locale='de'", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />, "de");
expect(html).toContain("Forschungsmodus");
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
expect(html).toContain("Deaktiviert");
});
});
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares the ten sections agreed for v1.4.16", () => {
it("declares the eleven sections agreed for v1.4.25", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
@@ -44,12 +44,17 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.4.16 phase B7 added the consolidated `export` section between
// `api` and `advanced` so every "give me my data out" path lives in
// one place.
// v1.4.25 W5e added `sources` between `thresholds` and `ai` so the
// per-metric-class source-priority surface lives next to the other
// analytics-shaping controls (thresholds → ranges; sources →
// canonical source per metric).
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
+41 -11
View File
@@ -43,6 +43,7 @@ import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { TimezonePicker } from "@/components/settings/timezone-picker";
interface PasskeyInfo {
id: string;
@@ -71,6 +72,7 @@ export function AccountSection() {
const [heightCm, setHeightCm] = useState("");
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>("Europe/Berlin");
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
@@ -132,6 +134,7 @@ export function AccountSection() {
: "",
);
setGender(user.gender ?? "");
setTimezone(user.timezone || "Europe/Berlin");
}
async function handleSaveProfile(e: React.FormEvent) {
@@ -140,23 +143,48 @@ export function AccountSection() {
setSaveMsg(null);
setSaveMsgType(null);
const res = await fetch("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim() || null,
heightCm: heightCm ? parseFloat(heightCm) : null,
dateOfBirth: dateOfBirth || null,
gender: gender || null,
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
fetch("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim() || null,
heightCm: heightCm ? parseFloat(heightCm) : null,
dateOfBirth: dateOfBirth || null,
gender: gender || null,
}),
}),
});
user && timezone && timezone !== user.timezone
? fetch("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
})
: Promise.resolve({ ok: true } as Response),
]);
if (res.ok) {
if (profileRes.ok && tzRes.ok) {
setSaveMsg(t("settings.profileSaved"));
setSaveMsgType("success");
await refetch();
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
// text. Surface its message verbatim so the user sees
// "Not a valid IANA timezone." instead of the generic save
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(json.error || t("settings.timezoneInvalid"));
} catch {
setSaveMsg(t("settings.timezoneInvalid"));
}
setSaveMsgType("error");
} else {
const json = await res.json();
const json = await profileRes.json();
setSaveMsg(json.error || t("settings.savingError"));
setSaveMsgType("error");
}
@@ -396,6 +424,8 @@ export function AccountSection() {
</div>
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
<select
+179 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -16,7 +16,14 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
type ResearchModeStatus,
researchModeGateState,
} from "@/lib/medications/research-mode-types";
/**
* Settings → Advanced.
@@ -26,6 +33,17 @@ import { useTranslations } from "@/lib/i18n/context";
* What stays here is the irreversible danger-zone — the "wipe all my
* data" surface that should never live next to a single-click export
* button.
*
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
* Research Mode and the data-reset are both opt-in / version-gated
* controls; they share a semantic shelf even though they touch
* different parts of the user record. Toggle ON opens the
* acknowledgment dialog (the user reads + confirms before the server
* stamps the version). Toggle OFF fires DELETE directly. A separate
* amber banner above the toggle catches the version-mismatch case
* (server bumped the disclaimer while the user was still
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
* dialog.
*/
export function AdvancedSection() {
const { t } = useTranslations();
@@ -47,11 +65,170 @@ export function AdvancedSection() {
</p>
</header>
<ResearchModeCard />
<DataResetCard />
</section>
);
}
/**
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
* the toggle's caption out of the JSX. The four-way ladder (loading /
* enabled-open / enabled-stale / disabled) is dense to read inline,
* and the surface stays open to a fifth state ("never queried") when
* we wire the offline shell in v1.4.26.
*/
function researchModeStatusLabel(
status: ResearchModeStatus | null | undefined,
isLoading: boolean,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (isLoading) return t("common.loading");
const gate = researchModeGateState(status);
if (gate === "off") return t("settings.researchMode.disabledStatus");
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
return t("settings.researchMode.acknowledgedOn", {
date: status?.acknowledgedAt
? formatDateTime(status.acknowledgedAt)
: "—",
});
}
function ResearchModeCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [toggleBusy, setToggleBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
queryKey: ["research-mode"],
queryFn: async () => {
const res = await fetch("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
},
staleTime: 60 * 1000,
});
const gateState = researchModeGateState(status);
const showRePrompt = gateState === "stale";
// The Switch's `checked` mirrors the server flag exactly — even when
// the version is stale we keep the toggle "on" so the user sees that
// their previous choice is preserved; the banner above the toggle
// explains why the chart isn't painting.
const switchChecked = !!status?.enabled;
async function handleToggle(next: boolean) {
if (toggleBusy) return;
setErrorMessage(null);
if (next) {
// Toggle ON → open the acknowledgment dialog. The dialog owns
// the POST; on success it invalidates the `research-mode` query
// and the toggle reflects the new state.
setDialogOpen(true);
return;
}
// Toggle OFF → fire DELETE directly.
setToggleBusy(true);
try {
const res = await fetch("/api/auth/me/research-mode", {
method: "DELETE",
});
if (!res.ok) {
setErrorMessage(t("settings.researchMode.disableError"));
return;
}
queryClient.invalidateQueries({ queryKey: ["research-mode"] });
} catch {
setErrorMessage(t("settings.researchMode.disableError"));
} finally {
setToggleBusy(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.subtitle")}
</p>
{showRePrompt && (
<div
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.rePromptBody")}
</p>
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => setDialogOpen(true)}
data-slot="settings-research-mode-reprompt-cta"
>
{t("settings.researchMode.rePromptCta")}
</Button>
</div>
)}
<div className="mt-4 flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
</p>
<p
className="text-muted-foreground text-xs"
data-slot="settings-research-mode-status"
>
{researchModeStatusLabel(status, isLoading, t)}
</p>
</div>
<Switch
checked={switchChecked}
disabled={isLoading || toggleBusy}
onCheckedChange={(next) => {
void handleToggle(next);
}}
aria-label={t("settings.researchMode.toggleLabel")}
data-slot="settings-research-mode-toggle"
/>
</div>
{errorMessage && (
<p
role="alert"
className="text-destructive mt-3 text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
</p>
)}
<ResearchModeAcknowledgmentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
/>
</div>
);
}
function DataResetCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -48,6 +48,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
// for the polished `<InsightsCardPreview>` (top severity-ordered AI
// recommendations + ring confidence meter + "View all" CTA).
insightsPreview: "dashboard.insightsPreview",
// v1.4.25 W8d — VO2 max secondary-metric tile (opt-in).
vo2Max: "dashboard.vo2Max",
};
export function DashboardLayoutSection({ id }: { id: string }) {
+9 -1
View File
@@ -105,6 +105,8 @@ interface ExportCardShellProps {
children?: React.ReactNode;
/** Footer slot — typically the "Download" button + status text. */
footer: React.ReactNode;
/** Extra grid-position classes applied to the outer card div. */
outerClassName?: string;
}
function ExportCardShell({
@@ -115,11 +117,12 @@ function ExportCardShell({
format,
children,
footer,
outerClassName,
}: ExportCardShellProps) {
return (
<div
data-testid={testId}
className="bg-card border-border flex h-full flex-col rounded-xl border p-5"
className={`bg-card border-border flex h-full flex-col rounded-xl border p-5${outerClassName ? ` ${outerClassName}` : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
@@ -180,6 +183,10 @@ function DoctorReportCard() {
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
// v1.4.25 W6c — per-section toggles. The aggregator drops
// disabled sections (mood is filtered server-side so the
// data never leaves the DB row).
sections: payload.sections,
}),
});
if (!res.ok) {
@@ -208,6 +215,7 @@ function DoctorReportCard() {
title={t("settings.sections.export.cards.doctorReport.title")}
description={t("settings.sections.export.cards.doctorReport.description")}
format="PDF"
outerClassName="md:col-span-2"
footer={
<Button
data-testid="export-action-doctor-report"
@@ -230,6 +230,12 @@ function WithingsCard({
lastSyncedAt?: string | null;
connectedAt?: string;
tokenExpired?: boolean;
// v1.4.25 W5d — reconnect banner conditional. `null` =
// legacy v1.4.24 connection without `user.activity`; the
// user needs to re-auth before steps / active energy /
// distance / floors ingest unlocks.
scope?: string | null;
hasActivityScope?: boolean;
};
},
enabled: isAuthenticated,
@@ -352,6 +358,32 @@ function WithingsCard({
<div className="mt-4 space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
Only paints when (a) the user has an active Withings
connection (so the credentials are already saved + a token
exists) AND (b) the persisted scope is missing
`user.activity`. Reconnecting takes the user through the
standard /api/withings/connect → Withings → callback flow;
the callback persists the upgraded scope and the banner
vanishes on the next status poll. */}
{status?.connected && status?.hasActivityScope === false && (
<a
href="/api/withings/connect"
data-testid="withings-reconnect-banner"
className="border-warning/30 bg-warning/10 text-warning-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
>
<span className="font-medium">
{t("settings.integrations.withings.reconnect.banner.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t("settings.integrations.withings.reconnect.banner.body")}
</span>
<span className="text-primary mt-1 inline-block text-xs font-medium">
{t("settings.integrations.withings.reconnect.banner.action")}
{" →"}
</span>
</a>
)}
<div className="space-y-3">
<h3 className="text-sm font-medium">
{t("settings.withingsCredentials")}
@@ -21,6 +21,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Bell,
CheckCircle2,
Clock,
Loader2,
@@ -123,9 +124,12 @@ export function NotificationStatusCard() {
className="bg-card border-border rounded-xl border p-6"
data-testid="notification-status-empty"
>
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
<div className="flex items-center gap-2">
<Bell className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{t("settings.notificationStatus.emptyDescription")}
</p>
@@ -136,9 +140,12 @@ export function NotificationStatusCard() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<header className="mb-4">
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
<div className="flex items-center gap-2">
<Bell className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.notificationStatus.description")}
</p>
@@ -16,6 +16,7 @@ import {
Download,
Info,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -35,6 +36,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
api: KeyRound,
export: Download,
+1
View File
@@ -12,6 +12,7 @@ export const SETTINGS_SECTION_SLUGS = [
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
Loaded 100 of 528 files, more files were not shown because too many files have changed in this diff. Show more