chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged

Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 23:13:21 +02:00
1 parent 324d4bb411
commit 6c7fc7dc84
167 files changed
+11233 -371

No files matched your search

@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11 — motion-reduce sweep guard.
*
* Every `animate-spin` site in component / app code must also carry
* `motion-reduce:animate-none` so that motion-sensitive users see a
* static icon instead of a continuous rotation. The audit found 21
* sites missing the modifier; this test prevents regressions.
*
* Scope: `src/**` excluding test files. The check is textual to avoid
* rendering every loading state — a CI-cheap guard that catches drift
* any time a new spinner is added without the modifier.
*/
const ROOT = join(process.cwd(), "src");
const EXCLUDE_DIRS = new Set(["__tests__", "node_modules"]);
function collectTsxFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (EXCLUDE_DIRS.has(entry)) continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
collectTsxFiles(full, acc);
} else if (
entry.endsWith(".tsx") &&
!entry.endsWith(".test.tsx") &&
!entry.endsWith(".test.ts")
) {
acc.push(full);
}
}
return acc;
}
describe("motion-reduce coverage — every animate-spin pairs with motion-reduce:animate-none", () => {
const files = collectTsxFiles(ROOT);
it("collects component files to scan", () => {
expect(files.length).toBeGreaterThan(50);
});
it("every animate-spin site declares motion-reduce:animate-none", () => {
const violations: string[] = [];
for (const file of files) {
const src = readFileSync(file, "utf8");
const lines = src.split("\n");
lines.forEach((line, idx) => {
if (!line.includes("animate-spin")) return;
// Skip comments and string literals that aren't classNames.
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
}
});
}
expect(
violations,
`The following animate-spin sites are missing motion-reduce:animate-none. ` +
`Append the modifier so motion-sensitive users see a static icon:\n` +
violations.join("\n"),
).toEqual([]);
});
});
@@ -166,7 +166,7 @@ export function ApiTokenOverviewSection() {
<div className="mt-4">
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : !tokens?.length ? (
// v1.4.15 phase-C5: empty state explains where tokens come
+5 -5
View File
@@ -362,7 +362,7 @@ export function BackupsSection() {
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
)}
@@ -418,7 +418,7 @@ export function BackupsSection() {
className="min-h-11"
>
{upload.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-1 h-3.5 w-3.5" />
)}
@@ -429,7 +429,7 @@ export function BackupsSection() {
{isLoading ? (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.section.backups.loading")}
</span>
@@ -459,7 +459,7 @@ export function BackupsSection() {
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
)}
@@ -518,7 +518,7 @@ export function BackupsSection() {
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
@@ -228,7 +228,7 @@ export function SystemStatusSection() {
data-testid="system-status-retry"
>
{isFetching ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="mr-1 h-3.5 w-3.5" />
)}
@@ -237,7 +237,7 @@ export function SystemStatusSection() {
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
</span>
@@ -50,4 +50,21 @@ describe("<ChartSkeleton>", () => {
const html = render(<ChartSkeleton mini />);
expect(html).toMatch(/h-\[140px\]/);
});
// v1.4.43 QoL (L4) — slow-hint caption appears after a 3 s grace.
// SSR markup never paints the caption because `useEffect` does not
// run server-side; this also keeps hydration mismatch-free.
it("does NOT paint the slow-hint caption in the initial SSR markup", () => {
const html = render(<ChartSkeleton />);
expect(html).not.toContain('data-slot="chart-skeleton-slow-hint"');
expect(html).not.toContain("Computing analytics");
});
it("mini variant never sets up the slow-hint timer", () => {
// Read the component source: the `useEffect` early-returns when
// `mini` is true, so a `<ChartSkeleton mini />` row in trends
// never paints the multi-line caption that would overflow it.
const html = render(<ChartSkeleton mini />);
expect(html).not.toContain('data-slot="chart-skeleton-slow-hint"');
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.43 W11-M6 — chart empty-window state.
*
* Pre-fix the chart returned `null` when the loaded data array was
* empty in the selected range, erasing the entire card (header +
* tabs + chart). The user saw a missing widget without explanation.
* The fix paints a "no data in this range" empty state via
* `<ChartEmptyState>` so the dashboard composition stays intact and
* the user knows the chart loaded but the selected window holds no
* measurements. The copy is distinct from the < 3-points "Mehr
* Messtage erforderlich" branch so the two situations don't blur.
*/
function buildData(rawCount: number): unknown[] {
const points: unknown[] = [];
Object.defineProperty(points, "rawCount", {
value: rawCount,
enumerable: false,
configurable: false,
writable: false,
});
return points;
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: null,
isLoading: false,
}),
}));
describe("<HealthChart> — empty-window state (W11-M6)", () => {
beforeEach(() => {
vi.resetModules();
});
it("renders the no-data-in-range copy when data resolves to an empty array", async () => {
const data = buildData(0);
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
// The new empty state must paint…
expect(html).toContain("Keine Daten in diesem Zeitraum");
// …and the chart card chrome (title) must still be mounted so the
// dashboard isn't missing a tile. The pre-fix `return null` would
// have stripped this entirely.
expect(html).toContain("Pulse");
// The < 3-points branch must NOT paint here — empty-window and
// sparse-data are different situations with distinct copy.
expect(html).not.toContain("Mehr Messtage erforderlich");
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
vi.doUnmock("@tanstack/react-query");
});
});
@@ -98,8 +98,8 @@ export function ChartOverlayControls({
<Button
type="button"
variant="ghost"
size="sm"
className={`text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-0 ${triggerClassName ?? ""}`.trim()}
size="default"
className={`text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-0 sm:min-h-9 sm:min-w-9 ${triggerClassName ?? ""}`.trim()}
aria-label={t("chart.overlay.controls.tooltip.openSettings")}
data-slot="chart-overlay-controls-trigger"
>
+37
View File
@@ -1,5 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -31,10 +33,21 @@ import { useTranslations } from "@/lib/i18n/context";
* `rounded-md p-2` and pins the inner band at 140 px so the loading
* shell occupies the same visible box as the painted chart.
*
* v1.4.43 QoL (L4) — when the skeleton is still painted three seconds
* in, surface a small caption ("Computing analytics — this can take
* a moment") so the user understands the chart is being computed
* rather than broken. Today the `/api/analytics` correlations branch
* can sit on the skeleton for ~9 s; the caption sets expectation.
* Only fires on the full variant — the mini-skeleton sits in a
* dashboard row strip where a multi-line caption would overflow.
*
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
* primitive's `animate-pulse` is suppressed via Tailwind's
* `motion-reduce:animate-none` modifier.
*/
const SLOW_HINT_DELAY_MS = 3_000;
export function ChartSkeleton({
className,
mini = false,
@@ -48,6 +61,21 @@ export function ChartSkeleton({
mini?: boolean;
}) {
const { t } = useTranslations();
// v1.4.43 QoL (L4) — `showSlowHint` flips true 3 s into the load so
// the caption only appears for genuinely slow renders. Using a
// `useEffect` keeps the SSR markup identical (caption absent) so
// hydration never mismatches; the timer kicks in client-side once
// the skeleton actually paints.
const [showSlowHint, setShowSlowHint] = useState(false);
useEffect(() => {
if (mini) return undefined;
const handle = setTimeout(
() => setShowSlowHint(true),
SLOW_HINT_DELAY_MS,
);
return () => clearTimeout(handle);
}, [mini]);
if (mini) {
return (
<div
@@ -93,6 +121,15 @@ export function ChartSkeleton({
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
contract `health-chart.tsx` paints at line 1087-1089. */}
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
{showSlowHint && (
<p
data-slot="chart-skeleton-slow-hint"
className="text-muted-foreground mt-3 text-xs"
>
{t("charts.loadingSlowHint")}
</p>
)}
</div>
);
}
+32 -3
View File
@@ -1132,7 +1132,14 @@ export function HealthChart({
return map;
}, [chartData, types]);
if (!isLoading && !data?.length) return null;
// v1.4.43 W11-M6 — keep the card shell mounted even when the loaded
// payload is empty. Pre-fix the early `return null` here erased the
// whole widget (header + range tabs + chart) and the user saw an
// unexplained gap on the dashboard. The in-card render branch below
// now paints a "no data in this range" empty state so the user
// understands the chart loaded but the selected window holds no
// measurements — distinct copy from the < 3-points "more days
// needed" hint so the two situations don't blur.
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
@@ -1286,9 +1293,31 @@ export function HealthChart({
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? (
) : !chartData?.length ? (
// v1.4.43 W11-M6 — empty-window state.
//
// Pre-fix the chart silently `return null`ed when `chartData`
// resolved empty (after-load, no points in the selected
// range). Paired with the < 3-points hint, the user's
// five visible chart states were:
//
// - skeleton (loading)
// - 1-2 daily points / many raw → "Mehr Messtage erforderlich"
// - 1-2 daily points / < 3 raw → "Erfasse mindestens 3 Einträge"
// - ≥ 3 daily points → line chart
// - 0 daily points → NOTHING
//
// The "nothing" branch read as a broken widget on the
// dashboard. Paint a distinct empty state explaining the
// selected range is empty so the user reaches for the range
// tabs or the quick-add instead of suspecting a bug.
<ChartEmptyState
title={t("charts.noDataInRangeTitle")}
description={t("charts.noDataInRangeDescription")}
/>
) : (chartData?.length ?? 0) < 3 ? (
// v1.4.16 B1a — sparse-data placeholder. <3 daily points is too
// few to render a meaningful trend; paint a friendly hint
// instead so the dashboard doesn't look broken.
@@ -123,9 +123,12 @@ export function RecentWorkoutsTile() {
</div>
{isLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
// (3 workouts × ~3 rem rows + spacing) so the surrounding
// dashboard layout doesn't reflow once the data lands.
<p
data-slot="recent-workouts-loading"
className="text-muted-foreground text-xs"
className="text-muted-foreground min-h-[10rem] text-xs"
>
{t("dashboard.recentWorkouts.loading")}
</p>
@@ -0,0 +1,177 @@
/**
* v1.4.43 QoL (M4) — `<SectionToggles>` strike-through disabled rows.
*
* Pre-fix, sections without data in the current range were filtered
* out of the rendered list, so a user had no signal that "Compliance"
* was a candidate. The fix renders every section in `SECTION_ORDER`;
* empties show with a strike-through label + tooltip explaining why
* they're disabled. Submitting still force-clears their toggle so the
* server never renders an empty section.
*
* Lives in its own SSR file because the `<DoctorReportDialog>` shell
* portals through Radix and is invisible to `renderToStaticMarkup`;
* here we exercise the toggle list directly through the internal
* `SectionToggles` export.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
__test_SECTION_ORDER,
SectionToggles,
} from "../doctor-report-dialog";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SectionToggles> v1.4.43 M4 disabled-row behaviour", () => {
it("renders a disabled row + strike-through for sections without data", () => {
const onToggle = vi.fn();
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: true,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={{ ...DEFAULT_DOCTOR_REPORT_PREFS, compliance: true }}
onToggle={onToggle}
/>,
);
// Every section row mounts — bp + weight available, others disabled.
for (const key of __test_SECTION_ORDER) {
expect(html).toContain(`data-testid="doctor-report-section-${key}"`);
}
// Pulse / compliance / sleep / mood / bmi rows carry the disabled
// marker so a future styling tweak can't silently re-enable them.
expect(html).toMatch(
/data-unavailable="true"[\s\S]*?Resting pulse/,
);
expect(html).toMatch(
/data-unavailable="true"[\s\S]*?Medication compliance/,
);
expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/);
// v1.4.43 W10 design-M4 — disabled rows render with muted text
// colour (not strike-through, which reads as "deleted" rather
// than "unavailable"). The italic hint line below carries the
// semantic visually.
expect(html).toMatch(/text-muted-foreground[\s\S]*?Sleep duration/);
});
it("renders the unavailable-hint tooltip text via the title attribute", () => {
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
);
// The disabled rows expose a `title=` attribute with the
// unavailable-hint copy so hovering a disabled toggle on desktop
// surfaces the explanation.
expect(html).toContain('title="No data in this period"');
});
it("renders disabled rows with the de locale's hint copy", () => {
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
"de",
);
// Every-section-empty triggers the wider empty-state hint instead
// of the per-row tooltip. Pin both copies are present in the de
// locale shipping.
expect(html).toContain("Für den gewählten Zeitraum");
});
it("renders disabled rows when at least one section has data", () => {
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
"de",
);
// German tooltip copy on the disabled rows.
expect(html).toContain('title="Keine Daten in diesem Zeitraum"');
});
it("force-clears the displayed checked state for unavailable rows", () => {
// Even if `prefs.compliance === true` (e.g. previously persisted),
// the displayed switch reads `false` when the section is not
// available in the range. The user can't toggle the disabled
// switch so the desync resolves itself; pin it here.
const html = render(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: true,
compliance: false,
sleep: true,
}}
availabilityLoading={false}
prefs={{ ...DEFAULT_DOCTOR_REPORT_PREFS, compliance: true }}
onToggle={vi.fn()}
/>,
);
// The compliance row exists and is marked unavailable…
expect(html).toMatch(
/data-unavailable="true"[\s\S]*?Medication compliance/,
);
// …and the disabled switch is rendered (aria-disabled or
// data-disabled survives in the SSR markup).
expect(html).toContain('data-testid="doctor-report-section-compliance"');
});
});
@@ -297,14 +297,12 @@ 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]);
// v1.4.43 QoL (M4) — pre-fix this memo built the on-screen toggle
// list, so sections without data in the current range vanished
// entirely. The dialog now renders the full `SECTION_ORDER` with
// strike-through disabled rows for the empties, and the submission
// payload still force-clears any unavailable toggle so the server
// never renders an empty section.
function toggleSection(key: keyof DoctorReportPrefs, value: boolean) {
setPrefs((current) => ({ ...current, [key]: value }));
@@ -454,8 +452,19 @@ export function DoctorReportDialog({
</div>
{/* ── Section toggles ──────────────────────────────────────── */}
{/*
v1.4.43 QoL (M4) — pass the full `SECTION_ORDER` (not the
availability-filtered `activeSections`) so the user sees a
row for every section that *could* be in the report. Rows
without data in the current range render disabled with a
strike-through label + tooltip, so the user understands
which section is empty and why their PDF has fewer
sections than expected. The submission payload still
force-clears any unavailable toggle, so the server never
renders an empty section.
*/}
<SectionToggles
activeSections={activeSections}
allSections={SECTION_ORDER}
availability={availability}
availabilityLoading={availabilityLoading}
prefs={prefs}
@@ -483,7 +492,7 @@ export function DoctorReportDialog({
</Button>
<Button type="submit" disabled={submitting || !validation.ok}>
{submitting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("doctorReport.dialog.submit")}
</Button>
@@ -496,8 +505,28 @@ export function DoctorReportDialog({
// ────────────────────────────── Subviews ──────────────────────────────
/**
* Exported alongside `SectionToggles` for the SSR component test. The
* order array is the canonical render order both inside the live
* dialog and inside the test.
*/
export const __test_SECTION_ORDER = [
"bp",
"weight",
"pulse",
"bmi",
"compliance",
"sleep",
"mood",
] as const satisfies ReadonlyArray<keyof DoctorReportPrefs>;
interface SectionTogglesProps {
activeSections: Array<keyof DoctorReportPrefs>;
/**
* Sections in render order — always the full `SECTION_ORDER` so the
* dialog shows the user every section that *could* be available, with
* disabled rows for ones that have no data in the current range.
*/
allSections: ReadonlyArray<keyof DoctorReportPrefs>;
availability: SectionAvailability | null;
availabilityLoading: boolean;
prefs: DoctorReportPrefs;
@@ -514,8 +543,14 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
sleep: "doctorReport.sections.sleep",
};
function SectionToggles({
activeSections,
/**
* Exported only so the SSR component test in
* `__tests__/doctor-report-section-toggles.test.tsx` can render the
* toggle list outside the Radix `Dialog` portal. Treat as internal:
* the public surface is `<DoctorReportDialog>`.
*/
export function SectionToggles({
allSections,
availability,
availabilityLoading,
prefs,
@@ -536,17 +571,22 @@ function SectionToggles({
{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 className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none 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) {
// v1.4.43 QoL (M4) — `availableCount` drives the empty-state hint.
// Pre-fix, an empty range filtered the list down to zero rows; now
// we always render every row with the empties disabled, so the
// empty-state guard kicks in only when *no* section has data.
const availableCount = availability
? allSections.filter((key) => availability[key]).length
: 0;
if (availability && availableCount === 0) {
return (
<div
className="border-border bg-muted/30 rounded-lg border p-3"
@@ -572,23 +612,44 @@ function SectionToggles({
{t("doctorReport.sections.title")}
</p>
{availabilityLoading && (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin motion-reduce:animate-none" />
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{activeSections.map((key) => {
{allSections.map((key) => {
const id = `dr-section-${key}`;
// v1.4.43 QoL (M4) — sections without data in the current
// range render disabled with a strike-through label and an
// explanatory tooltip; the user immediately understands
// why their PDF carries fewer sections than expected.
const isAvailable = availability?.[key] ?? false;
const unavailableHint = t("doctorReport.sections.unavailableHint");
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"
title={!isAvailable ? unavailableHint : undefined}
className={`-mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors ${
isAvailable
? "hover:bg-muted/50 cursor-pointer"
: "cursor-not-allowed opacity-60"
}`}
data-unavailable={!isAvailable ? "true" : undefined}
>
<span className="flex flex-col">
<span className="text-sm leading-tight font-medium">
<span
className={`text-sm leading-tight font-medium ${
isAvailable ? "" : "text-muted-foreground"
}`}
>
{t(SECTION_LABEL_KEYS[key])}
</span>
{key === "mood" && (
{!isAvailable && (
<span className="text-muted-foreground text-[11px] leading-tight italic">
{unavailableHint}
</span>
)}
{key === "mood" && isAvailable && (
<span className="text-muted-foreground text-[11px] leading-tight">
{t("doctorReport.sections.moodSensitive")}
</span>
@@ -598,7 +659,8 @@ function SectionToggles({
id={id}
data-testid={`doctor-report-section-${key}`}
size="sm"
checked={prefs[key]}
disabled={!isAvailable}
checked={isAvailable && prefs[key]}
onCheckedChange={(checked) => onToggle(key, checked)}
/>
</label>
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11 — correlation-card scatter skeleton class guard.
*
* The skeleton must mirror the loaded chart's responsive aspect-
* ratio classes (`scatter-correlation-chart.tsx:100`) so the
* placeholder reserves the same space across breakpoints. A simple
* substring check is enough — the only way to break this is to
* remove the `aspect-square` / `sm:aspect-[3/2]` / `sm:h-auto` /
* `min-h-[180px]` quartet, which the audit recommended pinning.
*/
describe("correlation-card scatter skeleton mirrors loaded chart", () => {
const cardSrc = readFileSync(
join(process.cwd(), "src/components/insights/correlation-card.tsx"),
"utf8",
);
const chartSrc = readFileSync(
join(
process.cwd(),
"src/components/charts/scatter-correlation-chart.tsx",
),
"utf8",
);
it("skeleton declares the chart's aspect-ratio quartet", () => {
// Capture only the dynamic-loader block so the assertion can't
// accidentally match a class somewhere else in the file.
const dynamicBlock = cardSrc.match(/loading: \(\) => \([\s\S]*?\),/);
expect(dynamicBlock).not.toBeNull();
const block = dynamicBlock?.[0] ?? "";
expect(block).toContain("aspect-square");
expect(block).toContain("min-h-[180px]");
expect(block).toContain("sm:aspect-[3/2]");
expect(block).toContain("sm:h-auto");
expect(block).toContain("motion-reduce:animate-none");
});
it("loaded scatter chart still owns the same aspect-ratio quartet", () => {
// If this regression ever silently flips on the loaded chart, the
// skeleton mirror loses its purpose. Pin both sides.
expect(chartSrc).toContain("aspect-square");
expect(chartSrc).toContain("min-h-[180px]");
expect(chartSrc).toContain("sm:aspect-[3/2]");
expect(chartSrc).toContain("sm:h-auto");
});
});
@@ -528,13 +528,20 @@ describe("errorCodeToI18nKey", () => {
"coach.provider.unavailable",
"coach.provider.empty",
"coach.provider.none",
"coach.network",
"coach.stream",
]) {
expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider");
}
});
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
// generic provider copy; the user needs the actionable offline hint.
it("maps coach.network to the dedicated offline key", () => {
expect(errorCodeToI18nKey("coach.network")).toBe(
"insights.coach.errorNetwork",
);
});
it("forward-compats unknown codes with the namespaced key", () => {
expect(errorCodeToI18nKey("errorProvider")).toBe(
"insights.coach.errorProvider",
@@ -82,10 +82,15 @@ export function errorCodeToI18nKey(code: string): string {
return "insights.coach.dailyLimitBody";
case "coach.provider.rate_limited":
return "insights.coach.providerRateLimitBody";
case "coach.network":
// v1.4.43 QoL (M6) — a dropped network is the user's local
// problem and needs a different next action ("come back online")
// than a provider failure ("try again in a moment"). Split out
// so the user sees the actionable copy in the offline branch.
return "insights.coach.errorNetwork";
case "coach.provider.unavailable":
case "coach.provider.empty":
case "coach.provider.none":
case "coach.network":
case "coach.stream":
return "insights.coach.errorProvider";
default:
+6 -1
View File
@@ -31,7 +31,12 @@ const ScatterCorrelationChart = dynamic(
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
// v1.4.43 W11 — mirror the loaded chart's responsive aspect-
// ratio classes (`scatter-correlation-chart.tsx:100`) so the
// skeleton reserves the same space across breakpoints. The
// legacy `h-[180px]` was ~60 px shorter than the painted chart
// at `sm+` and caused a visible CLS shift on insights mount.
<div className="bg-muted/40 aspect-square min-h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none sm:aspect-[3/2] sm:h-auto" />
),
},
);
+5 -5
View File
@@ -227,10 +227,10 @@ function BriefingSkeleton() {
aria-hidden="true"
>
<div className="space-y-2">
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse motion-reduce:animate-none rounded" />
</div>
<div className="space-y-2">
{[0, 1, 2].map((i) => (
@@ -238,7 +238,7 @@ function BriefingSkeleton() {
key={i}
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
>
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-1/3 animate-pulse motion-reduce:animate-none rounded" />
</div>
))}
</div>
@@ -377,7 +377,7 @@ function InsightsTabStripImpl({
data-slot="insights-tab-strip-group-item"
data-active={isChildActive ? "true" : undefined}
className={cn(
"flex min-h-10 items-center rounded-md px-2 py-1.5 text-sm transition-colors",
"flex min-h-11 items-center rounded-md px-2 py-1.5 text-sm transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isChildActive
? "bg-primary/10 text-primary"
+3 -3
View File
@@ -97,9 +97,9 @@ export function TrendAnnotation({
className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none"
aria-label={t("insights.trendAnnotation.pendingLabel")}
>
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse motion-reduce:animate-none rounded" />
</div>
);
}
@@ -0,0 +1,96 @@
/**
* v1.4.43 QoL (M5) — `<OfflineBanner>` SSR contract.
*
* The banner reads `navigator.onLine` + subscribes to `online`/`offline`
* window events inside a `useEffect`. SSR markup is therefore always
* empty (hidden); the effect flips the state once the component
* mounts client-side. Pin both halves of that contract here so a
* future refactor that paints during SSR (and creates a hydration
* mismatch) gets caught.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { OfflineBanner } from "../offline-banner";
const ROOT = join(__dirname, "../../../..");
const SHELL_PATH = join(ROOT, "src/components/layout/auth-shell.tsx");
const BANNER_PATH = join(ROOT, "src/components/layout/offline-banner.tsx");
function loadMessages(locale: string): { offlineBanner: { message: string } } {
const path = join(ROOT, "messages", `${locale}.json`);
return JSON.parse(readFileSync(path, "utf8"));
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<OfflineBanner>", () => {
it("renders nothing during SSR — hydration starts hidden", () => {
// The component initialises `isOnline === true`, so its first
// render (the SSR pass) returns `null`. The client-side effect
// then reads `navigator.onLine` and flips state if needed.
const html = render(<OfflineBanner />);
expect(html).toBe("");
});
it("ships the EN copy for `offlineBanner.message`", () => {
const en = loadMessages("en");
expect(en.offlineBanner.message).toBe(
"No connection — your changes will save once you're back online.",
);
});
it("ships the DE copy for `offlineBanner.message`", () => {
const de = loadMessages("de");
expect(de.offlineBanner.message).toBe(
"Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.",
);
});
it("ships the message key in every supported locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
}
});
it("mounts in auth-shell.tsx above the maintainership banner", () => {
const source = readFileSync(SHELL_PATH, "utf8");
expect(source).toContain('from "./offline-banner"');
expect(source).toContain("<OfflineBanner />");
});
});
// v1.4.43 QoL (M5) — also pin the client-side branch: when
// `isOnline === false` the banner paints with the i18n message,
// the WifiOff icon, and the `role="status"` aria-live region. We
// can't easily fire window events from a node-environment vitest
// suite, but we can read the component source and assert the
// branch carries the expected affordances.
describe("<OfflineBanner> rendered branch (source-level)", () => {
const source = readFileSync(BANNER_PATH, "utf8");
it("hosts an aria-live polite status region (assistive-tech friendly)", () => {
expect(source).toContain('role="status"');
expect(source).toContain('aria-live="polite"');
});
it("paints the WifiOff icon (visual signal for sighted users)", () => {
expect(source).toContain("WifiOff");
});
it("subscribes + unsubscribes to both online and offline window events", () => {
expect(source).toContain('window.addEventListener("online"');
expect(source).toContain('window.addEventListener("offline"');
expect(source).toContain('window.removeEventListener("online"');
expect(source).toContain('window.removeEventListener("offline"');
});
});
+8
View File
@@ -10,6 +10,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { BottomNav } from "./bottom-nav";
import { OfflineBanner } from "./offline-banner";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -160,6 +161,13 @@ 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">
{/*
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
`navigator.onLine === false`. Sits above the maintainership
banner + top bar so the connection-status hint is always
the first chrome line a user sees in the offline branch.
*/}
<OfflineBanner />
<MaintainershipBanner />
<TopBar />
<main
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import { WifiOff } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.43 QoL (M5) — slim banner that surfaces "you're offline" while
* `navigator.onLine` reads false. The PWA service worker already
* serves the read paths from cache (`public/sw.js`), but pre-fix a
* user toggling airplane mode mid-form-fill saw blank tiles and no
* explanation. This banner closes the explanation gap.
*
* Mount strategy: rendered once at the top of `<AuthShell>` so every
* authenticated route inherits it. The component is mount-time SSR-
* safe — it never paints during the initial server render (the
* `online`/`offline` window events only fire client-side); a `useEffect`
* subscribes to the events once and toggles the visible state from
* there. Honours `prefers-reduced-motion` via the absence of any
* animation.
*
* Copy lives in `messages/*.json` under `offlineBanner.message`; all
* six locales ship today.
*/
export function OfflineBanner() {
const { t } = useTranslations();
// Initialise to `true` so the banner stays hidden during SSR + the
// first client render. The effect below flips it true/false after
// mount based on `navigator.onLine`. (We do NOT read navigator on
// the very first render to keep the SSR markup hydration-stable.)
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
// Subscribe to the live transition events; both handlers route
// through the React setState pathway via the listener callback
// rather than a synchronous in-effect setState so the strict
// `react-hooks/set-state-in-effect` rule stays happy.
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync the initial state from `navigator.onLine` — deferred to a
// microtask so React sees this as an external-system bridge
// rather than a render-cascade setState. The legacy in-effect
// setState path tripped the strict lint rule, the queueMicrotask
// shape is the documented escape hatch.
queueMicrotask(() => {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
setIsOnline(false);
}
});
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
if (isOnline) return null;
return (
<div
role="status"
aria-live="polite"
data-slot="offline-banner"
className="bg-warning/15 border-warning/40 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
>
<WifiOff className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">{t("offlineBanner.message")}</span>
</div>
);
}
@@ -56,7 +56,7 @@ import {
} from "lucide-react";
import { Fragment, useId, useState } from "react";
import { createPortal } from "react-dom";
import { formatDateTime } from "@/lib/format";
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
@@ -568,8 +568,16 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
</span>
)}
</TableCell>
{/*
v1.4.43 QoL (L8) — `formatDateOrRelative`
renders timestamps inside the last 24 h
as "vor 3 min" so a fresh entry visible
on the dashboard briefing and on the
list view never disagrees about how to
phrase "when".
*/}
<TableCell className="text-muted-foreground text-sm">
{formatDateTime(m.measuredAt)}
{formatDateOrRelative(m.measuredAt, t)}
</TableCell>
<TableCell className="text-sm">
{m.notes ? (
@@ -714,7 +722,13 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
</span>
)}
<p className="text-muted-foreground truncate text-xs">
<span>{formatDateTime(m.measuredAt)}</span>
{/*
v1.4.43 QoL (L8) — see desktop
counterpart at the same `measuredAt`
site. Relative under 24 h, absolute
older.
*/}
<span>{formatDateOrRelative(m.measuredAt, t)}</span>
{m.source !== "MANUAL" && (
<Badge
variant="outline"
@@ -937,6 +951,18 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
</DropdownMenuContent>
</DropdownMenu>
{/*
v1.4.43 QoL (L7) — `[Cancel] [Save]` order is
iOS-first intentional per Apple HIG, which
puts the primary / confirmation action
rightmost. iOS is the load-bearing mobile
target for HealthLog and the web shell renders
desktop + iOS PWA from the same component, so
the current order survives. Do NOT flip to
`[Save] [Cancel]` in a future "Android parity"
refactor — fork a platform-specific shell
instead if Android becomes a real audience.
*/}
<div className="flex items-center gap-2">
<Button
type="button"
@@ -228,11 +228,13 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
knownState={researchMode}
/>
) : isLoading ? (
// v1.4.43 W11-L6 — match the loaded chart height (240 px) so the
// dashboard tile doesn't reflow when the levels render.
<div
className="flex h-[220px] items-center justify-center"
className="flex h-[240px] min-h-[240px] items-center justify-center"
data-slot="drug-level-chart-loading"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !hasDoses ? (
<EmptyState />
@@ -234,13 +234,13 @@ export function PhaseConfigDialog({
onChange={(e) =>
updateValue(key, parseInt(e.target.value, 10) || 0)
}
className="h-8 w-20 text-sm"
className="h-11 w-20 sm:h-9"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-12 px-0 text-xs"
className="h-11 w-12 px-0 text-xs sm:h-9"
onClick={() => toggleMode(key)}
>
{mode === "MINUTES"
+1 -1
View File
@@ -301,7 +301,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !data?.entries?.length ? (
// v1.4.15 phase-C5: replace bare-text empty rectangle with
@@ -16,6 +16,7 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
/**
@@ -119,11 +120,8 @@ export function WelcomeCarousel() {
`[data-slide-index="${idx}"]`,
);
if (!target) return;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
target.scrollIntoView({
behavior: reduceMotion ? "auto" : "smooth",
behavior: scrollBehaviorForUser(),
inline: "start",
block: "nearest",
});
@@ -0,0 +1,107 @@
/**
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` danger-zone shaping.
*
* M3: a separate "Delete account entirely" destructive card
* (`AccountDeleteCard`) sits beside the existing `DataResetCard`
* so a user reading the GDPR Article 17 surface has the full
* erasure CTA right next to the half-erasure one.
*
* L5: the data-reset card no longer paints a red `AlertTriangle`
* icon and the title sits in the neutral foreground colour
* (mirrors GitHub's danger-zone shaping). The CTA button stays
* red; the protective confirmation dialog stays unchanged.
*
* SSR-only — we render the component statically and inspect the
* markup. The mutation handlers don't fire in SSR, so this test only
* pins the visible structure + copy.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// `<AdvancedSection>` mounts the Research Mode card which calls
// `useQuery` and `useQueryClient` under TanStack Query. The other
// shape tests use the same stub.
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
ResearchModeAcknowledgmentDialog: () => (
<div data-slot="mock-ack-dialog" />
),
}));
import { AdvancedSection } from "../advanced-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AdvancedSection />
</I18nProvider>,
);
}
describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
it("mounts both the data-reset card and the account-delete card", () => {
const html = render();
expect(html).toContain('data-slot="settings-data-reset-card"');
expect(html).toContain('data-slot="settings-account-delete-card"');
});
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
const html = render();
// The reset card's title used to ship with `text-destructive`; the
// v1.4.43 fix pins it on `text-foreground`. We don't care if other
// strings in the section happen to use `text-destructive` (the CTA
// button does), only that the specific card title slot does NOT.
const cardMatch = html.match(
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
);
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).toContain("text-foreground");
expect(cardMatch![0]).not.toContain("text-destructive");
});
it("L5: the data-reset card no longer ships an AlertTriangle icon", () => {
const html = render();
// The icon used to render with a lucide-issued `lucide-alert-triangle`
// class. Pin the absence; if a future revision re-imports the icon
// the SVG class will reappear and this test catches it.
const cardMatch = html.match(
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
);
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/);
});
it("M3: the account-delete card uses the destructive Button variant", () => {
const html = render();
expect(html).toContain('data-slot="settings-account-delete-trigger"');
// The trigger button still wears the destructive style — the CTA
// remains red even though the surrounding card chrome is neutral.
const triggerMatch = html.match(
/data-slot="settings-account-delete-trigger"[\s\S]*?<\/button>/,
);
expect(triggerMatch).not.toBeNull();
// The destructive button uses the `bg-destructive` class from the
// shadcn variant.
expect(triggerMatch![0]).toContain("bg-destructive");
});
it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => {
const html = render("de");
expect(html).toContain("Konto vollständig löschen");
// The descriptive sentence calls out the cascade scope plainly.
expect(html).toContain("Passkeys");
});
it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => {
const html = render("en");
expect(html).toContain("Delete account entirely");
expect(html).toContain("passkeys");
});
});
@@ -94,4 +94,27 @@ describe("IntegrationStatusPill", () => {
// narrow on Pixel 5.
expect(html).toMatch(/just\s+now/i);
});
// v1.4.43 W14 — parked-state copy.
// Distinct from `error` (red reconnect pill) because the user can
// resume the integration without redoing the OAuth dance; distinct
// from `warning` because the persistent streak survived the alert
// ladder AND the 24h grace window — manual intervention required.
it("renders the parked state with manual-reconnect phrasing (EN)", () => {
const html = render(
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
);
expect(html).toContain("Paused");
expect(html).toContain("reconnect manually");
expect(html).toContain('data-state="parked"');
});
it("renders the parked state with manual-reconnect phrasing (DE)", () => {
const html = render(
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
"de",
);
expect(html).toContain("Pausiert");
expect(html).toContain("manuell wieder verbinden");
});
});
@@ -285,6 +285,50 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain("Last sync:");
});
// v1.4.43 W14 — parked-state surface check.
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused — reconnect manually" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "parked",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
consecutiveFailures: 30,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-08T12:00:00.000Z",
});
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("reconnect manually");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
// the contract-mismatch reason without opening Audit Log.
expect(html).toContain("Withings activity error: 293");
});
it("Mood Log card carries the visual divider Withings has (consistency)", () => {
setIntegrationStatus({
threshold: 3,
+1 -1
View File
@@ -211,7 +211,7 @@ export function AboutSection() {
{isLoading || !version ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
+3 -3
View File
@@ -359,7 +359,7 @@ export function AccountSection() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -617,7 +617,7 @@ export function AccountSection() {
className="w-full shrink-0 sm:w-auto"
>
{tourRestarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
)}
@@ -708,7 +708,7 @@ export function AccountSection() {
<Button type="submit" variant="outline" disabled={passwordSaving}>
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
+133 -8
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -68,6 +68,7 @@ export function AdvancedSection() {
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</section>
);
}
@@ -266,13 +267,18 @@ function DataResetCard() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive h-5 w-5" />
<h2 className="text-destructive text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
</div>
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-data-reset-card"
>
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
neutralised the title colour so the danger-zone shaping is
GitHub-style (red CTA only) rather than red-on-red-on-red.
The protective gate (confirmation dialog) is unchanged; this
is purely a visual-tone fix per the v1.4.43 audit. */}
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.dangerZoneDescription")}
</p>
@@ -322,3 +328,122 @@ function DataResetCard() {
</div>
);
}
/**
* v1.4.43 QoL (M3) — separate destructive action for the full account
* delete. Pre-fix, the only "danger zone" CTA wiped the user's health
* data but left the user row, passkeys, audit log, and sessions
* intact — half of what a user reading "Danger Zone" expects per
* GDPR Article 17. The route `DELETE /api/settings/account` already
* cascades User + passkeys + audit log + sessions (and is rate-limited
* by the API handler); this card is its UI front door.
*
* Visually the card mirrors the data-reset shaping (neutral title,
* red CTA only) but the dialog copy is explicit: this deletes the
* account, not just the data, and the user will be signed out
* immediately.
*/
function AccountDeleteCard() {
const { t } = useTranslations();
const [deleting, setDeleting] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
async function handleDeleteAccount() {
setDeleting(true);
setMsg(null);
setMsgType(null);
try {
const res = await fetch("/api/settings/account", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
});
if (!res.ok) {
try {
const json = await res.json();
setMsg(json.error || t("settings.deleteAccountFailed"));
} catch {
setMsg(t("settings.deleteAccountFailed"));
}
setMsgType("error");
return;
}
setMsg(t("settings.deleteAccountSuccess"));
setMsgType("success");
// The route destroyed every session before deleting the row;
// give the toast a beat to paint, then bounce to the login page.
setTimeout(() => {
window.location.href = "/auth/login";
}, 1_500);
} catch {
setMsg(t("settings.deleteAccountFailed"));
setMsgType("error");
} finally {
setDeleting(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-account-delete-card"
>
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
<div className="mt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteAccount}
data-slot="settings-account-delete-confirm"
>
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}
+3 -3
View File
@@ -639,7 +639,7 @@ function CodexProviderForm({
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-4 w-4" />
)}
@@ -682,7 +682,7 @@ function CodexProviderForm({
<div className="flex items-center gap-3 text-xs">
{devicePolling && (
<span className="text-muted-foreground inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin motion-reduce:animate-none" />
{t("settings.ai.deviceCodeWaiting")}
</span>
)}
@@ -703,7 +703,7 @@ function CodexProviderForm({
className="w-full sm:w-auto"
>
{devicePolling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
@@ -1,6 +1,11 @@
"use client";
import { CheckCircle2, AlertTriangle, CircleSlash } from "lucide-react";
import {
CheckCircle2,
AlertTriangle,
CircleSlash,
PauseCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
@@ -26,10 +31,24 @@ import { cn } from "@/lib/utils";
* abbreviated time form into a 12 char string at most.
*/
/**
* Pill states.
*
* - connected → happy path; relative "last sync" suffix is shown.
* - warning → connected but the upstream just returned a
* contract-mismatch error (v1.4.43 W4).
* - error → reauth-required / persistent transient error;
* "Error — reconnect" copy.
* - parked → v1.4.43 W14: >24h of persistent failures put the
* integration to sleep; "Pausiert — manuell wieder
* verbinden" copy + reconnect CTA.
* - disconnected → user clicked Disconnect; "Not connected" copy.
*/
export type IntegrationPillState =
| "connected"
| "warning"
| "error"
| "parked"
| "disconnected";
interface IntegrationStatusPillProps {
@@ -78,15 +97,22 @@ export function IntegrationStatusPill({
}: IntegrationStatusPillProps) {
const { t } = useTranslations();
// chipClass only applies to the `connected` and `warning` branches —
// `error` falls back to `variant="destructive"` and `disconnected` to
// `variant="outline"`. The warning chip uses amber tokens to signal
// "connected, but the upstream just returned a contract-level error
// a reauth won't fix" — distinct from the red reconnect pill.
// chipClass only applies to the `connected`, `warning`, and `parked`
// branches — `error` falls back to `variant="destructive"` and
// `disconnected` to `variant="outline"`. The warning chip uses amber
// tokens to signal "connected, but the upstream just returned a
// contract-level error a reauth won't fix" — distinct from the red
// reconnect pill. The parked chip uses the orange tokens to signal
// "we tried to keep going for 24h but the contract mismatch persists
// — manual intervention required" — visually heavier than warning
// (sustained problem), lighter than error (still recoverable without
// re-auth).
const connectedChipClass =
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
const warningChipClass =
"border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow";
const parkedChipClass =
"border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange";
let label: string;
let icon: React.ReactNode;
@@ -104,6 +130,10 @@ export function IntegrationStatusPill({
label = t("settings.integrationPill.errorReconnect");
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
break;
case "parked":
label = t("settings.integrationPill.parkedReconnect");
icon = <PauseCircle aria-hidden="true" className="h-3 w-3" />;
break;
case "disconnected":
label = t("settings.integrationPill.notConnected");
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
@@ -124,7 +154,7 @@ export function IntegrationStatusPill({
data-testid="integration-status-pill"
data-state={state}
variant={
state === "connected" || state === "warning"
state === "connected" || state === "warning" || state === "parked"
? undefined
: state === "error"
? "destructive"
@@ -135,6 +165,7 @@ export function IntegrationStatusPill({
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
state === "warning" && warningChipClass,
state === "parked" && parkedChipClass,
className,
)}
>
@@ -64,7 +64,8 @@ type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected";
| "disconnected"
| "parked";
interface IntegrationStatusViewModel {
integration: IntegrationKey;
@@ -73,6 +74,11 @@ interface IntegrationStatusViewModel {
lastAttemptAt: string | null;
lastError: string | null;
consecutiveFailures: number;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
persistent: number;
} | null;
configured?: boolean;
connected?: boolean;
connectedAt?: string | null;
@@ -114,11 +120,14 @@ function pickStatus(
}
/**
* Collapse the API's four-state machine into the three states the
* pill UI cares about: `error_transient` and `error_reauth` both
* Collapse the API's five-state machine into the four states the
* pill UI cares about. `error_transient` and `error_reauth` both
* surface as the same "Error — reconnect" pill, the actionable
* difference (whether the user must reconnect vs wait for the next
* retry) is conveyed via the inline error text underneath.
* retry) is conveyed via the inline error text underneath. `parked`
* (v1.4.43 W14) is its own pill state — the integration has been
* disabled after 24h of persistent failures and needs an explicit
* "Wieder verbinden" click to resume.
*/
function pillStateFor(
status: IntegrationStatusViewModel | undefined,
@@ -128,8 +137,22 @@ function pillStateFor(
case "connected":
return "connected";
case "error_transient":
// v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings
// rate-limit 601 / contract-mismatch 293/294) maps to the same
// `error_transient` DB state as a normal retryable failure but
// tells the user a different story: the access token still
// works, the upstream is responding with a non-recoverable
// status. Surfacing it as a "warning" pill (orange) instead of
// the red "Fehler — neu verbinden" stops the user from clicking
// reconnect ten times when reconnect can't fix it.
if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) {
return "warning";
}
return "error";
case "error_reauth":
return "error";
case "parked":
return "parked";
case "disconnected":
return "disconnected";
}
@@ -256,6 +279,28 @@ function WithingsCard({
},
});
// v1.4.43 W14 — clear a parked integration via the resume endpoint.
// The CTA is rendered inside the parked banner below; success
// invalidates both the per-card status (so the pill flips back to
// connected immediately) and the cross-integration envelope (so any
// other view picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
const res = await fetch("/api/integrations/withings/resume", {
method: "POST",
});
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
resumed: boolean;
wasParked: boolean;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncMsg(null);
@@ -339,8 +384,13 @@ function WithingsCard({
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
const errorMessage =
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;
(pillState === "error" || pillState === "parked") && viewModel?.lastError
? viewModel.lastError
: null;
return (
<div className="bg-card border-border rounded-xl border p-6">
@@ -388,6 +438,59 @@ function WithingsCard({
</span>
</a>
)}
{/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
when the row state is `parked` (>24h of persistent
failures). The button POSTs to /api/integrations/withings/
resume which calls `resumeIntegrationFromPark`; on success
the per-card status invalidates and the pill flips back to
connected without a page refresh. The button is the
primary action the user can take from this card — wider
tap target than the inline action row so it's reachable
on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="withings-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="withings-resume-button"
className="min-h-[44px] sm:min-h-0"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
data-testid="withings-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
data-testid="withings-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
{t("settings.withingsCredentials")}
+1 -1
View File
@@ -592,7 +592,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+5 -3
View File
@@ -20,10 +20,12 @@ function Switch({
// v1.4.43 W5-H1 — extend the WCAG 2.5.5 hit target to 44×44 px via
// a `::before` pseudo-element while leaving the visual track at
// 18×32 px. The same trick is used on Input (v1.4.34 IW-G). The
// wrapping `relative` and `before:absolute … before:inset-[-12px]`
// wrapping `relative` and `before:absolute … before:inset-[-13px]`
// pair guarantees pointer events bubble up from the larger zone
// without disturbing layout.
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-12px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
// without disturbing layout. v1.4.43 W10 design-M1 — `-13px` (not
// `-12px`) takes the 18.4 px track + 26 px padding to 44.4 px and
// closes the 1.6 px vertical short fall to spec-strict 2.5.5.
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-13px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}