mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(ui): announce failures, keep focus honest and stop the last layout shifts
Hidden states of the floating coach button now leave the tab order and accessibility tree, its focus ring carries an offset, its icon is dark on the gradient, an unread nudge is announced once via a live region, and it sits correctly above the fold on tablet widths and yields to the onboarding tour. Chart error cards announce themselves, share the exact height variables of the skeleton band, carry per-chart retry labels, and a failed chunk renders inside the regular card shell. The header icon cluster spaces its extended hit areas apart, the tab strip keeps horizontal paint room for focus rings, the streak row reserves its slot in every card state, the selection bar clears the bottom nav on landscape phones, and the version poller keeps an in-memory reload guard for browsers that block session storage. Mood notes render as plain paragraphs with a toggle that appears only when text is actually clamped, the remember control reveals on hover and hands focus to its confirmation, and the hypnogram names its source and paints only the lanes the night contains.
This commit is contained in:
1 parent
a2a995e429
commit
95f21bfa4d
29 files changed
+703
-137
No files matched your search
+2
-1
@@ -2480,7 +2480,8 @@
|
||||
"ariaLabel": "Schlafphasen-Verlauf der letzten Nacht: {asleep} geschlafen, {awakenings} Wachphasen",
|
||||
"asleepTotal": "Geschlafen {value}",
|
||||
"inBedTotal": "Im Bett {value}",
|
||||
"awakenings": "{count} Wachphasen"
|
||||
"awakenings": "{count} Wachphasen",
|
||||
"source": "Quelle: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Tief",
|
||||
|
||||
+2
-1
@@ -2480,7 +2480,8 @@
|
||||
"ariaLabel": "Sleep stage progression for last night: {asleep} asleep, {awakenings} awakenings",
|
||||
"asleepTotal": "Asleep {value}",
|
||||
"inBedTotal": "In bed {value}",
|
||||
"awakenings": "{count} awakenings"
|
||||
"awakenings": "{count} awakenings",
|
||||
"source": "Source: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Deep",
|
||||
|
||||
+2
-1
@@ -2480,7 +2480,8 @@
|
||||
"ariaLabel": "Progresión de las fases del sueño de la última noche: {asleep} dormido, {awakenings} despertares",
|
||||
"asleepTotal": "Dormido {value}",
|
||||
"inBedTotal": "En la cama {value}",
|
||||
"awakenings": "{count} despertares"
|
||||
"awakenings": "{count} despertares",
|
||||
"source": "Fuente: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profundo",
|
||||
|
||||
+2
-1
@@ -2480,7 +2480,8 @@
|
||||
"ariaLabel": "Progression des phases de sommeil de la nuit dernière : {asleep} de sommeil, {awakenings} réveils",
|
||||
"asleepTotal": "Sommeil {value}",
|
||||
"inBedTotal": "Au lit {value}",
|
||||
"awakenings": "{count} réveils"
|
||||
"awakenings": "{count} réveils",
|
||||
"source": "Source : {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profond",
|
||||
|
||||
+2
-1
@@ -2480,7 +2480,8 @@
|
||||
"ariaLabel": "Andamento delle fasi del sonno dell'ultima notte: {asleep} di sonno, {awakenings} risvegli",
|
||||
"asleepTotal": "Sonno {value}",
|
||||
"inBedTotal": "A letto {value}",
|
||||
"awakenings": "{count} risvegli"
|
||||
"awakenings": "{count} risvegli",
|
||||
"source": "Fonte: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profondo",
|
||||
|
||||
+2
-1
@@ -2480,7 +2480,8 @@
|
||||
"ariaLabel": "Przebieg faz snu ostatniej nocy: {asleep} snu, {awakenings} przebudzeń",
|
||||
"asleepTotal": "Sen {value}",
|
||||
"inBedTotal": "W łóżku {value}",
|
||||
"awakenings": "{count} przebudzeń"
|
||||
"awakenings": "{count} przebudzeń",
|
||||
"source": "Źródło: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Głęboki",
|
||||
|
||||
@@ -32,5 +32,16 @@ describe("<ChartEmptyState>", () => {
|
||||
<ChartEmptyState title="..." height={320} />,
|
||||
);
|
||||
expect(html).toContain("height:320px");
|
||||
expect(html).not.toContain("h-[var(--chart-height,240px)]");
|
||||
});
|
||||
|
||||
it("sizes through the chart-height variables by default", () => {
|
||||
// The former hardcoded `style="height:240px"` ignored per-mount
|
||||
// `--chart-height` overrides; the default now reads the same
|
||||
// variables the painted chart does (240 px mobile / 280 px md+).
|
||||
const html = renderToStaticMarkup(<ChartEmptyState title="..." />);
|
||||
expect(html).toContain("h-[var(--chart-height,240px)]");
|
||||
expect(html).toContain("md:h-[var(--chart-height-md,280px)]");
|
||||
expect(html).not.toContain("height:240px");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
// vi.doMock below invalidates the module registry, so the re-imported
|
||||
// provider gets a fresh (unprimed) locale cache — pass the DE bundle
|
||||
// explicitly instead of relying on the vitest.setup.ts seeding.
|
||||
@@ -65,6 +67,9 @@ describe("chart isError rendering — error state, not empty state", () => {
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
// Card chrome must stay mounted.
|
||||
expect(html).toContain("Pulse");
|
||||
// The retry button carries the chart title in its accessible name
|
||||
// so several failed charts on one page stay distinguishable.
|
||||
expect(html).toContain('aria-label="Erneut versuchen – Pulse"');
|
||||
// The empty-state copy must NOT paint — an outage is not "no data".
|
||||
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
|
||||
|
||||
@@ -113,3 +118,80 @@ describe("chart isError rendering — error state, not empty state", () => {
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function renderState(
|
||||
props: Partial<
|
||||
import("../chart-error-state").ChartErrorStateProps
|
||||
> = {},
|
||||
) {
|
||||
const { ChartErrorState } = await import("../chart-error-state");
|
||||
return renderToStaticMarkup(
|
||||
<ChartErrorState
|
||||
title="Data could not be loaded"
|
||||
actionLabel="Retry"
|
||||
onAction={() => undefined}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("announces the failure as a status by default", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).toContain('role="status"');
|
||||
});
|
||||
|
||||
it("escalates to alert when the boundary asks for it", async () => {
|
||||
const html = await renderState({ role: "alert" });
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
it("sizes through the chart-height variables by default", async () => {
|
||||
// The former hardcoded `style="height:240px"` ignored per-mount
|
||||
// `--chart-height` overrides; the default now reads the same
|
||||
// variables the painted chart does.
|
||||
const html = await renderState();
|
||||
expect(html).toContain("h-[var(--chart-height,240px)]");
|
||||
expect(html).toContain("md:h-[var(--chart-height-md,280px)]");
|
||||
expect(html).not.toContain("height:240px");
|
||||
});
|
||||
|
||||
it("keeps the explicit height prop as a per-mount override", async () => {
|
||||
const html = await renderState({ height: 140 });
|
||||
expect(html).toContain("height:140px");
|
||||
expect(html).not.toContain("h-[var(--chart-height,240px)]");
|
||||
});
|
||||
|
||||
it("joins the action context into the retry button's accessible name", async () => {
|
||||
const html = await renderState({ actionContext: "Pulse" });
|
||||
expect(html).toContain('aria-label="Retry – Pulse"');
|
||||
});
|
||||
|
||||
it("omits the aria-label without a context so the label is the name", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).not.toContain("aria-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChartErrorBoundary> — chunk-failure fallback", () => {
|
||||
it("wraps the fallback in the chart-card shell and escalates to alert", () => {
|
||||
// Error boundaries do not catch during static SSR, so the fallback
|
||||
// wiring is pinned structurally: the fallback card must carry the
|
||||
// solid chart-card shell (not a bare dashed box among solid cards)
|
||||
// and pass `role="alert"` (the whole chart is gone, not just data).
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/charts/chart-error-state.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain('data-slot="chart-error-boundary-card"');
|
||||
expect(src).toContain(
|
||||
'className="bg-card border-border rounded-xl border p-4 md:p-6"',
|
||||
);
|
||||
expect(src).toContain('role="alert"');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { LineChart as LineChartIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
|
||||
*
|
||||
@@ -29,9 +31,11 @@ export interface ChartEmptyStateProps {
|
||||
*/
|
||||
description?: ReactNode;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<HealthChart>` /
|
||||
* `<MedicationComplianceChart>`).
|
||||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||||
* contexts. Absent, the card sizes through the same
|
||||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||||
* reads (240 px mobile / 280 px md+ by default), so a per-mount
|
||||
* override resizes the empty state too.
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
@@ -39,13 +43,17 @@ export interface ChartEmptyStateProps {
|
||||
export function ChartEmptyState({
|
||||
title,
|
||||
description,
|
||||
height = 240,
|
||||
height,
|
||||
}: ChartEmptyStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-empty-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
className={cn(
|
||||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center",
|
||||
height === undefined &&
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||||
)}
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<LineChartIcon
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Error-state card + per-chart error boundary.
|
||||
@@ -31,30 +32,59 @@ export interface ChartErrorStateProps {
|
||||
/** Action handler — query `refetch` or a full page reload. */
|
||||
onAction: () => void;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<ChartEmptyState>`).
|
||||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||||
* contexts. Absent, the card sizes through the same
|
||||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||||
* reads, so a per-mount override resizes the error card too.
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* Optional translated context (usually the chart title). Joins the
|
||||
* action label into the button's accessible name so several retry
|
||||
* buttons on one page stay distinguishable for screen-reader users.
|
||||
*/
|
||||
actionContext?: string;
|
||||
/**
|
||||
* Live-region semantics. `status` (default) announces politely when
|
||||
* a data query fails inside an otherwise healthy card; the chunk
|
||||
* boundary fallback passes `alert` because the surrounding chart is
|
||||
* gone entirely.
|
||||
*/
|
||||
role?: "status" | "alert";
|
||||
}
|
||||
|
||||
export function ChartErrorState({
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
height = 240,
|
||||
height,
|
||||
actionContext,
|
||||
role = "status",
|
||||
}: ChartErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-error-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
role={role}
|
||||
className={cn(
|
||||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center",
|
||||
height === undefined &&
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||||
)}
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<Button variant="outline" size="sm" onClick={onAction}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
aria-label={
|
||||
actionContext ? `${actionLabel} – ${actionContext}` : undefined
|
||||
}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
@@ -111,11 +141,21 @@ export function ChartErrorBoundary({
|
||||
return (
|
||||
<Boundary
|
||||
fallback={
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
/>
|
||||
// Wrapped in the chart-card shell so a chunk failure renders
|
||||
// as one solid card among the other cards, not a bare dashed
|
||||
// box; `role="alert"` because the whole chart is gone, not
|
||||
// just its data.
|
||||
<div
|
||||
data-slot="chart-error-boundary-card"
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
role="alert"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1531,6 +1531,7 @@ export function HealthChart({
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={title}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
|
||||
@@ -424,6 +424,7 @@ export function MedicationComplianceChart({
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !hasData ? (
|
||||
|
||||
@@ -613,6 +613,7 @@ export function MoodChart({
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
onAction={() => void refetch()}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
|
||||
@@ -62,10 +62,12 @@ export function SelectionActionBar({
|
||||
// edge. A `sticky bottom-2` bar lands BEHIND the nav, hiding the
|
||||
// destructive "Delete (N)" button. Lift the bar to clear the nav band
|
||||
// + safe area using the same offset the Coach FAB uses
|
||||
// (`layout-coach-fab.tsx`); at `sm:`/desktop the nav is gone, so drop
|
||||
// back to `bottom-4`. z-30 keeps the bar above page content without
|
||||
// needing to beat the (now-cleared) nav's z-50.
|
||||
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
|
||||
// (`layout-coach-fab.tsx`); the nav only hides from `md:` up
|
||||
// (`md:hidden`), so the drop back to `bottom-4` happens at `md:` —
|
||||
// an `sm:` drop sat the bar behind the still-visible nav 640-767px.
|
||||
// z-30 keeps the bar above page content without needing to beat the
|
||||
// (now-cleared) nav's z-50.
|
||||
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg md:bottom-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -406,4 +406,17 @@ describe("<InsightsTabStrip> — pill row clip box (v1.16.8)", () => {
|
||||
expect(scroller).toContain("py-1");
|
||||
expect(scroller).toContain("-my-1");
|
||||
});
|
||||
|
||||
it("keeps horizontal paint room so edge pills' focus rings survive", () => {
|
||||
// Same clip mechanics sideways: without inner `px-1` the first/last
|
||||
// pill's focus ring was cut at the scroller's horizontal edges. The
|
||||
// matching `-mx-1` hands the width back to the layout.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
const scroller = html.match(
|
||||
/<div[^>]*data-slot="insights-tab-strip-scroller"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(scroller).toBeTruthy();
|
||||
expect(scroller).toContain("px-1");
|
||||
expect(scroller).toContain("-mx-1");
|
||||
});
|
||||
});
|
||||
@@ -55,11 +55,49 @@ describe("<LayoutCoachFab>", () => {
|
||||
expect(html).toContain('data-slot="coach-fab"');
|
||||
// Accessible name carries the launcher label, not the nudge copy.
|
||||
expect(html).toContain('aria-label="Open the Coach"');
|
||||
// Fixed bottom-right, above the mobile bottom-nav band.
|
||||
// Fixed bottom-right, above the mobile bottom-nav band. The plain
|
||||
// desktop offset kicks in at `md:` — the same breakpoint where the
|
||||
// bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air
|
||||
// in the 768-1023px band.
|
||||
expect(html).toContain("fixed right-4");
|
||||
expect(html).toContain(
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
|
||||
);
|
||||
expect(html).toContain("md:bottom-6");
|
||||
expect(html).not.toContain("lg:bottom-6");
|
||||
});
|
||||
|
||||
it("paints a dark glyph + offset focus ring on the gradient", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// White on the purple/pink gradient sat at ≈2.3:1; the background
|
||||
// token reads ≈6.5:1.
|
||||
expect(html).toContain("text-background");
|
||||
expect(html).not.toContain("text-white");
|
||||
// The offset ring draws a visible halo around the gradient circle.
|
||||
expect(html).toContain("focus-visible:ring-offset-2");
|
||||
expect(html).toContain("focus-visible:ring-offset-background");
|
||||
});
|
||||
|
||||
it("carries a polite live region for the unread-nudge announcement", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// The swapped aria-label is not announced on mutation; the sr-only
|
||||
// sibling emits the nudge copy once on the unread rising edge. It
|
||||
// must be mounted (and empty) BEFORE a nudge arrives so the live
|
||||
// region is registered with the accessibility tree.
|
||||
const live = html.match(/<span[^>]*data-slot="coach-fab-live"[^>]*>/)?.[0];
|
||||
expect(live).toBeTruthy();
|
||||
expect(live).toContain('aria-live="polite"');
|
||||
expect(live).toContain("sr-only");
|
||||
});
|
||||
|
||||
it("paints no unread dot on first paint (no nudge-status data yet)", () => {
|
||||
@@ -93,6 +131,31 @@ describe("<LayoutCoachFab>", () => {
|
||||
// The selection bar's delete action lands in the same lower-right
|
||||
// band; the FAB carries a CSS gate keyed off the bar's data-slot.
|
||||
expect(html).toContain("selection-action-bar");
|
||||
// The gate must include `invisible` — `opacity-0` alone left the
|
||||
// hidden button focusable and operable: a keyboard user could tab
|
||||
// onto an unseeable control and trigger a navigation. (The `&` is
|
||||
// HTML-escaped in static markup.)
|
||||
expect(html).toContain(
|
||||
"[body:has([data-slot=selection-action-bar])_&]:invisible",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"[body:has([data-slot=selection-action-bar])_&]:pointer-events-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("yields to the onboarding tour overlay with the same robust gate", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
"[body:has([data-testid=onboarding-tour])_&]:invisible",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"[body:has([data-testid=onboarding-tour])_&]:pointer-events-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
SleepHypnogram,
|
||||
type SleepHypnogramSession,
|
||||
} from "../sleep-hypnogram";
|
||||
|
||||
/**
|
||||
* v1.16.8 — hypnogram polish: the night's measuring source rides the
|
||||
* header as a muted caption, and the depth lanes derive from the stages
|
||||
* actually present in the night's spans (in `LANE_ORDER` order) instead
|
||||
* of always painting all six — an Apple night (AWAKE/REM/CORE/DEEP)
|
||||
* used to carry two permanently empty rows.
|
||||
*
|
||||
* Project convention: SSR-only tests. Recharts' `ResponsiveContainer`
|
||||
* paints nothing without measured dimensions, so the lane derivation is
|
||||
* pinned structurally against the source.
|
||||
*/
|
||||
|
||||
const baseSession: SleepHypnogramSession = {
|
||||
night: "2026-06-09",
|
||||
source: "Apple Health",
|
||||
start: "2026-06-08T22:30:00.000Z",
|
||||
end: "2026-06-09T06:00:00.000Z",
|
||||
asleepMinutes: 420,
|
||||
inBedMinutes: 450,
|
||||
awakeMinutes: 30,
|
||||
awakenings: 2,
|
||||
stages: { DEEP: 90, REM: 100, CORE: 230, AWAKE: 30 },
|
||||
segments: [
|
||||
{
|
||||
stage: "AWAKE",
|
||||
start: "2026-06-08T22:30:00.000Z",
|
||||
end: "2026-06-08T22:45:00.000Z",
|
||||
minutes: 15,
|
||||
},
|
||||
{
|
||||
stage: "CORE",
|
||||
start: "2026-06-08T22:45:00.000Z",
|
||||
end: "2026-06-09T01:00:00.000Z",
|
||||
minutes: 135,
|
||||
},
|
||||
{
|
||||
stage: "DEEP",
|
||||
start: "2026-06-09T01:00:00.000Z",
|
||||
end: "2026-06-09T02:30:00.000Z",
|
||||
minutes: 90,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function render(session: SleepHypnogramSession, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<SleepHypnogram session={session} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SleepHypnogram> — source caption", () => {
|
||||
it("surfaces the night's measuring source as a muted header caption", () => {
|
||||
const html = render(baseSession);
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-source"');
|
||||
expect(html).toContain("Source: Apple Health");
|
||||
});
|
||||
|
||||
it("localises the caption label", () => {
|
||||
const html = render(baseSession, "de");
|
||||
expect(html).toContain("Quelle: Apple Health");
|
||||
});
|
||||
|
||||
it("omits the caption when the session carries no source", () => {
|
||||
const html = render({ ...baseSession, source: null });
|
||||
expect(html).not.toContain('data-slot="sleep-hypnogram-source"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SleepHypnogram> — lane derivation", () => {
|
||||
it("derives the Y lanes from the stages present in the spans", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/insights/sleep-hypnogram.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// Lanes come from the present stages, preserving LANE_ORDER…
|
||||
expect(src).toContain("LANE_ORDER.filter((stage) => present.has(stage))");
|
||||
// …and the axis reads the derived lane list, not the full constant.
|
||||
expect(src).toContain("domain={[-0.5, lanes.length - 0.5]}");
|
||||
expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}");
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,12 @@ describe("<SubPageShell>", () => {
|
||||
html.lastIndexOf('data-slot="metric-show-all-values"'),
|
||||
);
|
||||
expect(html).not.toContain("w-full sm:w-auto");
|
||||
// The cluster gap must clear the siblings' extended hit areas
|
||||
// (`before:-inset-1.5` = 6 px per edge → ≥12 px gap): with the old
|
||||
// `gap-0.5` the later sibling's invisible halo overlapped its
|
||||
// neighbour's clickable edge.
|
||||
expect(html).toContain("items-center gap-3");
|
||||
expect(html).not.toContain("items-center gap-0.5");
|
||||
});
|
||||
|
||||
it("omits the show-all-values control without a type", () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
@@ -524,6 +526,42 @@ describe("<MessageThread>", () => {
|
||||
expect(html).not.toContain('data-slot="coach-remember-message"');
|
||||
});
|
||||
|
||||
it("reveals the remember control on bubble hover/focus at sm+, keeps it visible on touch", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
const button = html.match(
|
||||
/<button[^>]*data-slot="coach-remember-message"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(button).toBeTruthy();
|
||||
// Hidden only behind BOTH gates — `sm:` viewport AND a
|
||||
// hover-capable pointer — so touch devices (no hover media) keep
|
||||
// the control always visible.
|
||||
expect(button).toContain("sm:[@media(hover:hover)]:opacity-0");
|
||||
expect(button).toContain(
|
||||
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
|
||||
);
|
||||
expect(button).toContain(
|
||||
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
|
||||
);
|
||||
// The bubble column is the named hover/focus group.
|
||||
expect(html).toContain("group/user-bubble");
|
||||
});
|
||||
|
||||
it("hands focus to the settled confirmation instead of dropping it to body", () => {
|
||||
// The settled branch unmounts the button the user just activated;
|
||||
// the status paragraph takes the focus (`tabIndex={-1}` +
|
||||
// programmatic focus on settle). SSR cannot exercise the focus
|
||||
// call, so the wiring is pinned structurally.
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/insights/coach-panel/message-thread.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain("statusRef.current?.focus()");
|
||||
expect(src).toMatch(/ref=\{statusRef\}\s*\n\s*tabIndex=\{-1\}/);
|
||||
});
|
||||
|
||||
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
|
||||
it("surfaces the daily-limit copy for coach.budget.exceeded", () => {
|
||||
const html = render(
|
||||
|
||||
@@ -476,7 +476,9 @@ function ChatBubble({
|
||||
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
|
||||
// of the 80% cap so the bubble + avatar together never
|
||||
// overflow a comfortable width on a narrow phone.
|
||||
"flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
|
||||
// `group/user-bubble` scopes the remember control's
|
||||
// hover/focus reveal (see `RememberUserMessage`).
|
||||
"group/user-bubble flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -728,6 +730,14 @@ function ChatBubble({
|
||||
function RememberUserMessage({ content }: { content: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
|
||||
// On settle the button unmounts while it holds focus, which would
|
||||
// drop keyboard focus to <body>. The confirmation paragraph takes
|
||||
// the focus instead (`tabIndex={-1}` + programmatic focus) so the
|
||||
// reading position survives the swap.
|
||||
const statusRef = useRef<HTMLParagraphElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (settled) statusRef.current?.focus();
|
||||
}, [settled]);
|
||||
|
||||
const remember = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -747,8 +757,10 @@ function RememberUserMessage({ content }: { content: string }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
ref={statusRef}
|
||||
tabIndex={-1}
|
||||
data-slot="coach-remember-message-done"
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs"
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
|
||||
>
|
||||
<Check className="text-dracula-green size-3" aria-hidden="true" />
|
||||
{t(
|
||||
@@ -770,6 +782,16 @@ function RememberUserMessage({ content }: { content: string }) {
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs",
|
||||
"outline-none focus-visible:ring-2 disabled:opacity-50",
|
||||
// Calmer thread on pointer devices: the control stays invisible
|
||||
// until its bubble is hovered or holds focus. Touch viewports
|
||||
// (no hover media) keep it always visible — there is nothing to
|
||||
// hover. `opacity-0` (not `invisible`) keeps it focusable so
|
||||
// keyboard users can reach it; focus then reveals it.
|
||||
"sm:[@media(hover:hover)]:opacity-0",
|
||||
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
|
||||
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
|
||||
"sm:[@media(hover:hover)]:focus-visible:opacity-100",
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
)}
|
||||
>
|
||||
{remember.isPending ? (
|
||||
|
||||
@@ -634,8 +634,9 @@ function InsightsTabStripImpl({
|
||||
// ring (+2 px offset) was cut on every side. `py-1` keeps
|
||||
// 4 px of paint room inside the clip box; the matching `-my-1`
|
||||
// gives that room back to the layout so the strip height is
|
||||
// unchanged.
|
||||
className="-my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto py-1 [&::-webkit-scrollbar]:hidden"
|
||||
// unchanged. `px-1 -mx-1` does the same horizontally so the
|
||||
// first/last pill's focus ring isn't clipped at the row edges.
|
||||
className="-mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
if (tab.kind === "link") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
@@ -92,6 +92,33 @@ export function LayoutCoachFab() {
|
||||
const nudgedAt = status?.nudgedAt ?? null;
|
||||
const unread = isNudgeUnread(status, seenStamp);
|
||||
|
||||
// The unread dot is visual-only (`aria-hidden`) and the swapped
|
||||
// `aria-label` is not announced on mutation — a screen-reader user
|
||||
// never hears about a fresh nudge. The polite live region below the
|
||||
// button emits the nudge copy once on the unread rising edge and
|
||||
// clears when the nudge is read, so the announcement fires exactly
|
||||
// once per nudge.
|
||||
const [liveAnnouncement, setLiveAnnouncement] = useState("");
|
||||
const prevUnreadRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (unread && !prevUnreadRef.current) {
|
||||
setLiveAnnouncement(t("insights.coach.nudgeBubbleLabel"));
|
||||
} else if (!unread && prevUnreadRef.current) {
|
||||
setLiveAnnouncement("");
|
||||
}
|
||||
prevUnreadRef.current = unread;
|
||||
}, [unread, t]);
|
||||
|
||||
// While a chart tooltip hides the FAB it is `aria-hidden` +
|
||||
// `tabIndex={-1}` — but an element that ALREADY holds focus keeps it.
|
||||
// Drop the focus so a hidden control is never the active element.
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (tooltipActive && document.activeElement === buttonRef.current) {
|
||||
buttonRef.current?.blur();
|
||||
}
|
||||
}, [tooltipActive]);
|
||||
|
||||
// Visiting the Coach page itself counts as reading the nudge on this
|
||||
// device — persist the stamp so the dot stays gone after leaving.
|
||||
useEffect(() => {
|
||||
@@ -125,42 +152,72 @@ export function LayoutCoachFab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
data-unread={unread ? "true" : undefined}
|
||||
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset on mobile; plain bottom offset on desktop.
|
||||
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] lg:bottom-6",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
// Fade out while a Recharts tooltip is open (see header note).
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
// Yield to the data-list selection bar: its delete action lands
|
||||
// in the same lower-right band, and the destructive control
|
||||
// wins. The `:has()` gate keys off the bar's `data-slot`.
|
||||
"[body:has([data-slot=selection-action-bar])_&]:pointer-events-none",
|
||||
"[body:has([data-slot=selection-action-bar])_&]:opacity-0",
|
||||
tooltipActive && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-5" aria-hidden="true" />
|
||||
{unread ? (
|
||||
<span
|
||||
data-slot="coach-fab-unread"
|
||||
aria-hidden="true"
|
||||
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
data-unread={unread ? "true" : undefined}
|
||||
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset on mobile; plain bottom offset once the
|
||||
// bottom-nav hides (`md:hidden` on the nav, so `md:` here —
|
||||
// not `lg:` — keeps the FAB from floating mid-air 768-1023px).
|
||||
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-6",
|
||||
// Dark glyph on the purple/pink gradient — white sat at
|
||||
// ≈2.3:1 against the gradient midpoint; the background token
|
||||
// reads ≈6.5:1.
|
||||
"from-dracula-purple to-dracula-pink text-background bg-gradient-to-br",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
// The default ring alone is hard to see against the gradient;
|
||||
// the offset ring draws a clear halo around the circle.
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
// Fade out while a Recharts tooltip is open (see header note).
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
// Yield to the data-list selection bar: its delete action lands
|
||||
// in the same lower-right band, and the destructive control
|
||||
// wins. The `:has()` gate keys off the bar's `data-slot`.
|
||||
// `invisible` (visibility:hidden) removes the hidden button
|
||||
// from the tab order + accessibility tree — `opacity-0`
|
||||
// alone left it focusable and operable while unseeable.
|
||||
"[body:has([data-slot=selection-action-bar])_&]:pointer-events-none",
|
||||
"[body:has([data-slot=selection-action-bar])_&]:opacity-0",
|
||||
"[body:has([data-slot=selection-action-bar])_&]:invisible",
|
||||
// Same yield while the onboarding tour overlay is up — the
|
||||
// tour dims the page and drives focus itself; a floating
|
||||
// launcher on top of the spotlight is noise.
|
||||
"[body:has([data-testid=onboarding-tour])_&]:pointer-events-none",
|
||||
"[body:has([data-testid=onboarding-tour])_&]:opacity-0",
|
||||
"[body:has([data-testid=onboarding-tour])_&]:invisible",
|
||||
tooltipActive && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-5" aria-hidden="true" />
|
||||
{unread ? (
|
||||
<span
|
||||
data-slot="coach-fab-unread"
|
||||
aria-hidden="true"
|
||||
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
{/* Polite announcement for the unread-nudge arrival — see the
|
||||
rising-edge effect above. */}
|
||||
<span
|
||||
data-slot="coach-fab-live"
|
||||
aria-live="polite"
|
||||
className="sr-only"
|
||||
>
|
||||
{liveAnnouncement}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,10 @@ export interface SleepHypnogramProps {
|
||||
|
||||
/**
|
||||
* Lane order top → bottom. Awake highest, Deep lowest — depth increases
|
||||
* downward, matching Apple Health / Oura's "cityscape" convention.
|
||||
* downward, matching Apple Health / Oura's "cityscape" convention. Only
|
||||
* the stages actually present in the night's spans get a lane (the
|
||||
* order is preserved) — painting all six lanes left an Apple night
|
||||
* (AWAKE/REM/CORE/DEEP) with two permanently empty rows.
|
||||
*/
|
||||
const LANE_ORDER = [
|
||||
"AWAKE",
|
||||
@@ -76,10 +79,6 @@ const LANE_ORDER = [
|
||||
"CORE",
|
||||
"DEEP",
|
||||
] as const;
|
||||
const LANE_OF: Record<string, number> = Object.fromEntries(
|
||||
// Reverse-index so AWAKE (first) sits at the highest y value.
|
||||
LANE_ORDER.map((s, i) => [s, LANE_ORDER.length - 1 - i]),
|
||||
);
|
||||
|
||||
/** Breakdown order — deepest restorative first, matching the stacked bar. */
|
||||
const BREAKDOWN_ORDER = [
|
||||
@@ -115,21 +114,33 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
// Build one coloured span per stage segment: its [start, end] clock range
|
||||
// at the stage's depth lane, in the stage's own palette token. Each span
|
||||
// renders as a `ReferenceArea` so the chart is multi-coloured (a single
|
||||
// Recharts line can only carry one stroke).
|
||||
const spans = useMemo(() => {
|
||||
return [...session.segments]
|
||||
.filter((seg) => seg.stage != null && LANE_OF[seg.stage] !== undefined)
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||
.map((seg) => {
|
||||
const stage = seg.stage as string;
|
||||
return {
|
||||
stage,
|
||||
x1: new Date(seg.start).getTime(),
|
||||
x2: new Date(seg.end).getTime(),
|
||||
lane: LANE_OF[stage],
|
||||
fill: STAGE_COLORS[stage],
|
||||
};
|
||||
});
|
||||
// Recharts line can only carry one stroke). Lanes derive from the stages
|
||||
// PRESENT in the spans (in `LANE_ORDER` order) so a four-stage night
|
||||
// paints four lanes, not six.
|
||||
const { spans, lanes } = useMemo(() => {
|
||||
const known = new Set<string>(LANE_ORDER);
|
||||
const segments = [...session.segments]
|
||||
.filter((seg) => seg.stage != null && known.has(seg.stage))
|
||||
.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
|
||||
);
|
||||
const present = new Set(segments.map((seg) => seg.stage as string));
|
||||
const lanes = LANE_ORDER.filter((stage) => present.has(stage));
|
||||
const laneOf: Record<string, number> = Object.fromEntries(
|
||||
// Reverse-index so the first present stage sits at the highest y.
|
||||
lanes.map((s, i) => [s, lanes.length - 1 - i]),
|
||||
);
|
||||
const spans = segments.map((seg) => {
|
||||
const stage = seg.stage as string;
|
||||
return {
|
||||
stage,
|
||||
x1: new Date(seg.start).getTime(),
|
||||
x2: new Date(seg.end).getTime(),
|
||||
lane: laneOf[stage],
|
||||
fill: STAGE_COLORS[stage],
|
||||
};
|
||||
});
|
||||
return { spans, lanes };
|
||||
}, [session.segments]);
|
||||
|
||||
const domain = useMemo<[number, number]>(
|
||||
@@ -216,6 +227,20 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
asleep: formatMinutes(session.asleepMinutes, locale),
|
||||
})}
|
||||
</span>
|
||||
{/* The night's measuring source rides the header as a muted
|
||||
caption — it is already on the wire, and "which device
|
||||
tracked this" is the first question a multi-tracker user
|
||||
asks about a surprising night. */}
|
||||
{session.source ? (
|
||||
<span
|
||||
data-slot="sleep-hypnogram-source"
|
||||
className="text-muted-foreground/70 text-xs"
|
||||
>
|
||||
{t("insights.sleep.hypnogram.source", {
|
||||
source: session.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -253,16 +278,14 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="lane"
|
||||
domain={[-0.5, LANE_ORDER.length - 0.5]}
|
||||
ticks={LANE_ORDER.map(
|
||||
(_, i) => LANE_ORDER.length - 1 - i,
|
||||
)}
|
||||
domain={[-0.5, lanes.length - 0.5]}
|
||||
ticks={lanes.map((_, i) => lanes.length - 1 - i)}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
width={56}
|
||||
tickFormatter={(v: number) => {
|
||||
const stage = LANE_ORDER[LANE_ORDER.length - 1 - v];
|
||||
return stageLabels[stage] ?? "";
|
||||
const stage = lanes[lanes.length - 1 - v];
|
||||
return stage ? (stageLabels[stage] ?? "") : "";
|
||||
}}
|
||||
/>
|
||||
{spans.map((span, i) => (
|
||||
|
||||
@@ -226,8 +226,13 @@ export function SubPageShell({
|
||||
no numeric band registers nothing, so the gear stays hidden),
|
||||
and the Coach icon self-gates on the Coach flag + per-user
|
||||
opt-out — so the cluster collapses cleanly to one, both, or
|
||||
neither control. */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
neither control.
|
||||
|
||||
`gap-3` (12 px) keeps the siblings' extended hit areas
|
||||
(`before:-inset-1.5`, 6 px per edge) from overlapping —
|
||||
with a tighter gap the later sibling's invisible halo sat
|
||||
on top of its neighbour's clickable edge. */}
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{/* v1.16.8 — "show all readings" rides the header cluster
|
||||
as an icon button, LEFT of the target-adjust gear (it
|
||||
used to be a full-width outline button at the page foot).
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
MedicationComplianceBars,
|
||||
MedicationComplianceError,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
@@ -88,6 +89,25 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
expect(html).not.toContain("lucide-flame");
|
||||
});
|
||||
|
||||
it("compliance bars reserve the streak row across loaded, zero-streak, skeleton, and error states", () => {
|
||||
// A streak arriving used to ADD a row, growing the card and shifting
|
||||
// the grid; the `min-h-4` slot is now always mounted in every state so
|
||||
// the card height is invariant to the streak.
|
||||
const withStreak = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={5} />,
|
||||
);
|
||||
const withoutStreak = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
);
|
||||
const skeleton = render(<MedicationComplianceSkeleton />);
|
||||
const error = render(
|
||||
<MedicationComplianceError onRetry={() => undefined} />,
|
||||
);
|
||||
for (const html of [withStreak, withoutStreak, skeleton, error]) {
|
||||
expect(html).toContain("min-h-4");
|
||||
}
|
||||
});
|
||||
|
||||
it("compliance bars render the percentage only — no dose count beside it", () => {
|
||||
// v1.15.9 — the operator does not want a count next to the percentage.
|
||||
// The auto-miss engine now makes the two windows' percentages genuinely
|
||||
|
||||
@@ -94,10 +94,13 @@ export function MedicationComplianceBars({
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
{/* Streak flame — only mounted when there's a streak so an empty
|
||||
row doesn't leave a residual gap below the bars. */}
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{/* Streak flame — the row is ALWAYS mounted at `min-h-4` so a
|
||||
streak arriving (or expiring) never changes the card height
|
||||
and the action row below stays on one baseline across a grid
|
||||
row. The flame itself still gates on `streak > 0`; the
|
||||
skeleton and error fallbacks reserve the same slot. */}
|
||||
<div className="flex min-h-4 items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
<span className="text-warning flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak}{" "}
|
||||
@@ -105,8 +108,8 @@ export function MedicationComplianceBars({
|
||||
? t("medications.dayStreakOne")
|
||||
: t("medications.dayStreak")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,9 +124,10 @@ export function MedicationComplianceBars({
|
||||
* footprint so the card body keeps a constant inventory and the action row
|
||||
* pins to the same baseline across the row.
|
||||
*
|
||||
* The streak-flame row height is intentionally NOT reserved — the flame is
|
||||
* gated on `streak > 0` on the loaded card too, so reserving it would make
|
||||
* every streak-less card taller than its own loaded self.
|
||||
* The streak-flame slot IS reserved (`min-h-4`, matching the loaded card's
|
||||
* always-mounted row) — the loaded card reserves it whether or not a streak
|
||||
* exists, so the skeleton must too or a resolving card would grow by one
|
||||
* row and shift the grid.
|
||||
*/
|
||||
export function MedicationComplianceSkeleton() {
|
||||
return (
|
||||
@@ -142,6 +146,7 @@ export function MedicationComplianceSkeleton() {
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="min-h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -184,6 +189,8 @@ export function MedicationComplianceError({
|
||||
</div>
|
||||
<div className="bg-muted/40 h-2 rounded" />
|
||||
</div>
|
||||
{/* Streak-slot parity with the loaded card + skeleton. */}
|
||||
<div className="min-h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,24 +83,40 @@ function render(locale: "en" | "de" = "de") {
|
||||
}
|
||||
|
||||
describe("MoodList — full-text note affordance", () => {
|
||||
it("renders the note as a collapsed toggle with the COMPLETE text in the DOM", () => {
|
||||
it("renders the note as a plain clamped paragraph with the COMPLETE text", () => {
|
||||
const html = render("de");
|
||||
// One toggle per surface (desktop table + mobile card) for the noted row;
|
||||
// the note-less row renders none.
|
||||
const toggles = html.match(/data-testid="mood-note-toggle"/g);
|
||||
expect(toggles?.length).toBe(2);
|
||||
// Collapsed by default: clamped preview + expand affordance label.
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
// One note paragraph per surface (desktop table + mobile card) for the
|
||||
// noted row; the note-less row renders none. The note is a PARAGRAPH
|
||||
// sibling of the toggle — not button content — so the accessible name
|
||||
// of the toggle never swallows the whole note.
|
||||
const notes = html.match(/data-testid="mood-note-text"/g);
|
||||
expect(notes?.length).toBe(2);
|
||||
expect(html).toContain("line-clamp-2");
|
||||
expect(html).toContain("Notiz vollständig anzeigen");
|
||||
// The complete text is in the row (the clamp is visual), both paragraphs.
|
||||
expect(html).toContain("Erster Absatz der Notiz");
|
||||
expect(html).toContain("Zweiter Absatz mit weiteren Details");
|
||||
// The toggle only mounts once the client measures an actual overflow
|
||||
// (`scrollHeight > clientHeight`); static SSR has no layout, so no
|
||||
// toggle — a short note never gets a dangling "show more" control.
|
||||
expect(html).not.toContain('data-testid="mood-note-toggle"');
|
||||
});
|
||||
|
||||
it("localises the affordance label", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain("Show full note");
|
||||
it("wires the toggle as a small sibling button with state + target", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/mood/mood-list.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// The toggle carries the disclosure contract: aria-expanded for the
|
||||
// state, aria-controls pointing at the paragraph's id.
|
||||
expect(src).toContain("aria-expanded={expanded}");
|
||||
expect(src).toContain("aria-controls={noteId}");
|
||||
expect(src).toContain('data-testid="mood-note-toggle"');
|
||||
// Clamp detection: measured overflow, re-checked on resize.
|
||||
expect(src).toContain("scrollHeight > el.clientHeight");
|
||||
expect(src).toContain("new ResizeObserver(updateClamped)");
|
||||
// Rendered only when the text actually overflows the clamp (or while
|
||||
// expanded, so it can collapse again).
|
||||
expect(src).toContain("{(expanded || clamped) && (");
|
||||
});
|
||||
|
||||
it("expanded branch preserves line breaks and renders plain text children", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -997,9 +997,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/**
|
||||
* v1.16.8 — expandable free-text note. Collapsed: a two-line clamped
|
||||
* preview. Expanded: the COMPLETE text, line breaks preserved, rendered as
|
||||
* plain text children. The whole block is one toggle button so the full
|
||||
* note is readable in place — previously the only full-text surface was
|
||||
* the edit sheet (plus a hover tooltip on desktop, unreachable on touch).
|
||||
* plain text children. The full note is readable in place — previously the
|
||||
* only full-text surface was the edit sheet (plus a hover tooltip on
|
||||
* desktop, unreachable on touch).
|
||||
*
|
||||
* Structure: the note is a plain paragraph with an `id`; the toggle is a
|
||||
* SMALL sibling button carrying `aria-expanded` + `aria-controls`. The
|
||||
* earlier cut wrapped the whole paragraph in the button, which made the
|
||||
* entire note text the button's accessible name and put block content
|
||||
* inside a button (invalid content model).
|
||||
*
|
||||
* The toggle only renders when the collapsed paragraph actually
|
||||
* overflows its two-line clamp (`scrollHeight > clientHeight`, re-checked
|
||||
* on resize) — a one-line note gets no dangling "show more" control.
|
||||
*/
|
||||
function MoodNoteText({
|
||||
note,
|
||||
@@ -1011,15 +1021,36 @@ function MoodNoteText({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const noteId = useId();
|
||||
const paragraphRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [clamped, setClamped] = useState(false);
|
||||
|
||||
const updateClamped = useCallback(() => {
|
||||
const el = paragraphRef.current;
|
||||
if (!el) return;
|
||||
// +1 tolerates sub-pixel rounding on fractional zoom levels.
|
||||
setClamped(el.scrollHeight > el.clientHeight + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only the collapsed paragraph carries the clamp; measuring the
|
||||
// expanded one would always read "fits" and drop the collapse
|
||||
// affordance while it is needed most.
|
||||
if (expanded) return;
|
||||
updateClamped();
|
||||
const el = paragraphRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(updateClamped);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [expanded, note, updateClamped]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
data-testid="mood-note-toggle"
|
||||
className="mt-0.5 block w-full min-w-0 cursor-pointer text-left"
|
||||
>
|
||||
<div className="mt-0.5 min-w-0">
|
||||
<p
|
||||
id={noteId}
|
||||
ref={paragraphRef}
|
||||
data-testid="mood-note-text"
|
||||
className={
|
||||
expanded
|
||||
? "text-muted-foreground/80 text-xs break-words whitespace-pre-wrap italic"
|
||||
@@ -1028,10 +1059,19 @@ function MoodNoteText({
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
<span className="text-dracula-purple text-[11px] font-medium">
|
||||
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
|
||||
</span>
|
||||
</button>
|
||||
{(expanded || clamped) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={noteId}
|
||||
data-testid="mood-note-toggle"
|
||||
className="text-dracula-purple cursor-pointer text-[11px] font-medium"
|
||||
>
|
||||
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,13 +77,21 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory mirror of the session guard: under strict-privacy modes
|
||||
// sessionStorage throws, and without a fallback a persistent
|
||||
// shell/live mismatch (misconfigured build) would reload every poll.
|
||||
// Module scope survives re-renders but not the reload itself — after
|
||||
// a reload the storage read is retried first, so the mirror only has
|
||||
// to break the loop within one document lifetime.
|
||||
let inMemoryReloadGuard: string | null = null;
|
||||
|
||||
async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
inMemoryReloadGuard = targetVersion;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
|
||||
} catch {
|
||||
// sessionStorage can throw under strict-privacy modes; fall
|
||||
// through to the reload anyway — worst case the next deploy
|
||||
// re-triggers the same heal flow.
|
||||
// sessionStorage can throw under strict-privacy modes; the
|
||||
// in-memory mirror above still breaks same-document loops.
|
||||
}
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
@@ -120,8 +128,9 @@ export function VersionPoller(): null {
|
||||
try {
|
||||
lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY);
|
||||
} catch {
|
||||
/* fall through — treat as "no attempt recorded" */
|
||||
/* storage unavailable — the in-memory mirror still applies */
|
||||
}
|
||||
lastReloadedFor ??= inMemoryReloadGuard;
|
||||
const decision = resolveVersionPollDecision(
|
||||
live,
|
||||
SHELL_VERSION,
|
||||
|
||||
Reference in new issue
Block a user