mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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't");
|
||||
expect(html).toContain("Why it'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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user