mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user