fix(insights): polish range pills, zero-delta caption, scrollbar gutter, mood tiles

- Range pills: make the radiogroup a single tab stop with roving tabindex and
  Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern.
  Previously every pill was its own tab stop and the arrow keys were dead.
- Range delta caption: render a neutral dash + "no change" for a perfectly
  stable metric instead of a down-arrow with "−0%". Adds the `noChange`
  string across all six locales.
- Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the
  body-scrolled shells (login / register / onboarding / legal) via a
  `data-scroll` attribute the shell sets, so the height-locked authenticated
  branch no longer reserves a second, never-painted gutter on classic
  scrollbar platforms.
- Mood tiles: a lone in-target / stability tile now spans full width instead
  of leaving a half-width orphan beside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 17:34:52 +02:00
1 parent b13721cc97
commit 107a111545
13 files changed
+237 -21

No files matched your search

+2 -1
View File
@@ -1624,7 +1624,8 @@
"30d": "ggü. vorherigen 30T",
"90d": "ggü. vorherigen 90T",
"1y": "ggü. Vorjahr"
}
},
"noChange": "keine Änderung"
},
"correlationRow": {
"title": "Zusammenhänge",
+2 -1
View File
@@ -1624,7 +1624,8 @@
"30d": "vs prior 30d",
"90d": "vs prior 90d",
"1y": "vs prior year"
}
},
"noChange": "no change"
},
"correlationRow": {
"title": "Relationships",
+2 -1
View File
@@ -1624,7 +1624,8 @@
"30d": "vs 30d anteriores",
"90d": "vs 90d anteriores",
"1y": "vs año anterior"
}
},
"noChange": "sin cambios"
},
"correlationRow": {
"title": "Correlaciones",
+2 -1
View File
@@ -1624,7 +1624,8 @@
"30d": "vs 30j précédents",
"90d": "vs 90j précédents",
"1y": "vs année précédente"
}
},
"noChange": "aucun changement"
},
"correlationRow": {
"title": "Corrélations",
+2 -1
View File
@@ -1624,7 +1624,8 @@
"30d": "vs 30g precedenti",
"90d": "vs 90g precedenti",
"1y": "vs anno precedente"
}
},
"noChange": "nessuna variazione"
},
"correlationRow": {
"title": "Correlazioni",
+2 -1
View File
@@ -1624,7 +1624,8 @@
"30d": "vs poprzednie 30d",
"90d": "vs poprzednie 90d",
"1y": "vs poprzedni rok"
}
},
"noChange": "bez zmian"
},
"correlationRow": {
"title": "Korelacje",
+14 -9
View File
@@ -253,16 +253,21 @@
@apply border-border outline-ring/50;
}
/*
* Reserve the scrollbar gutter on the document root so the body-scrolled
* shells (login / register / onboarding / standalone legal pages) keep a
* fixed content width whether or not the page overflows. Without it a tall
* page paints the bar and a short one hides it, and any `mx-auto` content
* recentres a few px sideways on navigation. The authenticated app shell
* scrolls inside its own `<main>` (`flex h-dvh` locks the body height, so
* the document never scrolls there) and reserves its own gutter — no
* double gutter results.
* Reserve the scrollbar gutter on the document root ONLY for the
* body-scrolled shells (login / register / onboarding / standalone legal
* pages), where the document itself overflows. Those keep a fixed content
* width whether or not the page paints the bar, so any `mx-auto` content
* does not recentre a few px sideways on navigation. `AuthShell` sets
* `data-scroll="body"` on `<html>` for exactly those branches.
*
* The authenticated app shell is height-locked (`flex h-dvh`) and scrolls
* inside its own `<main>`, which reserves its own gutter. Applying this
* rule globally there reserved a SECOND, never-painted gutter on classic
* (non-overlay) scrollbar platforms (Windows / Linux) — a ~15px dead band.
* Scoping it to the body-scrolled branch leaves exactly one gutter per
* branch.
*/
html {
html[data-scroll="body"] {
scrollbar-gutter: stable;
}
body {
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { MetricRangeDelta } from "../metric-range-delta";
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
/**
* v1.9.0 QA (Design M1 / Correctness L-1) — a perfectly stable metric
* (delta === 0 / rounded 0 %) must read neutral: a flat dash and "no
* change", never a down-arrow with "−0%".
*/
function render(data: AnalyticsRangeData | undefined) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricRangeDelta
data={data}
range="30d"
directionSentiment="up-good"
isLoading={false}
/>
</I18nProvider>,
);
}
const base: AnalyticsRangeData = {
range: "30d",
windowDays: 30,
granularity: "DAY",
current: { count: 1, min: 1, max: 1, mean: 1, sum: 1 },
previous: { count: 1, min: 1, max: 1, mean: 1, sum: 1 },
delta: 0,
deltaPct: 0,
};
describe("MetricRangeDelta — zero-delta neutral", () => {
it("renders neutral copy + no directional arrow for a zero delta", () => {
const html = render(base);
expect(html).toContain('data-direction="neutral"');
expect(html).toContain("no change");
// No misleading "−0%".
expect(html).not.toContain("0%");
expect(html).not.toContain("−");
});
it("still renders a directional +pct for a real increase", () => {
const html = render({ ...base, delta: 2, deltaPct: 0.1 });
expect(html).toContain('data-direction=');
expect(html).not.toContain('data-direction="neutral"');
expect(html).toContain("+10%");
});
it("shows the no-prior-data line when the comparison is null", () => {
const html = render({ ...base, delta: null, deltaPct: null });
expect(html).toContain("metric-range-delta-empty");
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TimeRangePills } from "../time-range-pills";
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
/**
* v1.9.0 QA (Design H1) — the range pills are a WAI-ARIA APG radiogroup, so
* the group must be a SINGLE tab stop: only the checked radio carries
* `tabIndex={0}`, every other radio is `-1` (roving tabindex). Before the
* fix all four were native buttons (four tab stops) with no arrow-key
* handler. Project convention is SSR-only tests, so we assert the rendered
* roving-tabindex contract + radio semantics rather than driving live key
* events.
*/
function render(value: (typeof ANALYTICS_RANGES)[number]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TimeRangePills value={value} onChange={() => {}} />
</I18nProvider>,
);
}
describe("TimeRangePills — roving tabindex (single tab stop)", () => {
it("renders a radiogroup with one radio per range", () => {
const html = render("30d");
expect(html).toContain('role="radiogroup"');
const radios = html.match(/role="radio"/g) ?? [];
expect(radios).toHaveLength(ANALYTICS_RANGES.length);
});
it("makes only the checked radio tabbable (tabindex 0); the rest are -1", () => {
const html = render("30d");
// Exactly one tabindex=0 (the checked radio is the single tab stop).
const tabbable = html.match(/tabindex="0"/g) ?? [];
expect(tabbable).toHaveLength(1);
// The remaining three are removed from the tab order.
const untabbable = html.match(/tabindex="-1"/g) ?? [];
expect(untabbable).toHaveLength(ANALYTICS_RANGES.length - 1);
});
it("marks the chosen value selected (aria-checked + data-selected)", () => {
const html = render("90d");
// Exactly one radio is checked / selected.
expect(html.match(/aria-checked="true"/g) ?? []).toHaveLength(1);
expect(html.match(/data-selected="true"/g) ?? []).toHaveLength(1);
// The chosen pill is present and selected.
expect(html).toMatch(/data-range="90d"/);
});
});
+22 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { ArrowDown, ArrowUp } from "lucide-react";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -62,8 +62,28 @@ export function MetricRangeDelta({
const direction = getTrendSentiment(data.delta, directionSentiment);
const colorClass = sentimentColorClass(direction);
const isUp = data.delta > 0;
const pct = Math.round(Math.abs(data.deltaPct) * 100);
// A perfectly stable metric (current mean == prior mean, or a rounded
// delta of 0 %) reads neutral — a flat dash and "no change", never a
// down-arrow with "−0%". The direction arrow is decoupled from the raw
// sign so a zero delta never paints a misleading decrease.
if (data.delta === 0 || pct === 0) {
return (
<span
data-slot="metric-range-delta"
data-direction="neutral"
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
>
<Minus className="h-3 w-3" aria-hidden="true" />
<span>
{t("insights.range.noChange")} {priorLabel}
</span>
</span>
);
}
const isUp = data.delta > 0;
const sign = isUp ? "+" : "−";
return (
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
@@ -140,9 +141,20 @@ export function MoodInsightsSections() {
return (
<div className="space-y-4">
{(hasInTargetTile || hasStabilityTile) && (
<div className="grid gap-4 sm:grid-cols-2">
<MoodInTargetTile pct={data.summary.inTargetPct} />
<MoodStabilityTile stability={data.stability} />
// Two-up only when BOTH tiles render; a lone tile spans full width so
// it never leaves a half-width orphan with dead space beside it.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && (
<MoodStabilityTile stability={data.stability} />
)}
</div>
)}
+46 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { useRef, type KeyboardEvent } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
@@ -15,6 +17,11 @@ import {
* pulling a new Radix dependency — the project's tab strip uses the same
* custom-pill approach. The selected range drives the period-over-period
* delta caption and (where wired) the chart window.
*
* Follows the WAI-ARIA APG radiogroup pattern: the group is a single tab
* stop (only the checked radio is `tabIndex={0}`, the rest `-1`) and the
* arrow keys / Home / End move selection with a roving focus, so a keyboard
* user does not tab through all four and the arrow keys are not dead.
*/
export function TimeRangePills({
value,
@@ -26,25 +33,63 @@ export function TimeRangePills({
className?: string;
}) {
const { t } = useTranslations();
const radiosRef = useRef<Array<HTMLButtonElement | null>>([]);
const selectAt = (index: number) => {
const next = ANALYTICS_RANGES[index];
onChange(next);
// Roving focus follows selection so the newly checked radio is focused.
radiosRef.current[index]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const current = ANALYTICS_RANGES.indexOf(value);
const last = ANALYTICS_RANGES.length - 1;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
selectAt(current >= last ? 0 : current + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
selectAt(current <= 0 ? last : current - 1);
break;
case "Home":
event.preventDefault();
selectAt(0);
break;
case "End":
event.preventDefault();
selectAt(last);
break;
}
};
return (
<div
role="radiogroup"
aria-label={t("insights.range.label")}
data-slot="time-range-pills"
onKeyDown={onKeyDown}
className={cn(
"bg-muted/50 inline-flex items-center gap-0.5 rounded-lg p-0.5",
className,
)}
>
{ANALYTICS_RANGES.map((range) => {
{ANALYTICS_RANGES.map((range, index) => {
const selected = range === value;
return (
<button
key={range}
ref={(el) => {
radiosRef.current[index] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
data-range={range}
data-selected={selected}
onClick={() => onChange(range)}
+20
View File
@@ -47,6 +47,26 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
// only for the body-scrolled shells (login / register / onboarding /
// standalone legal pages). The authenticated branch is height-locked and
// scrolls inside its own `<main>`, which reserves its own gutter; applying
// the document gutter there too produced a second, never-painted gutter on
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
// so the CSS rule scopes to them.
const isBodyScrolled = isPublicPage || isOnboardingPage;
useEffect(() => {
const root = document.documentElement;
if (isBodyScrolled) {
root.setAttribute("data-scroll", "body");
} else {
root.removeAttribute("data-scroll");
}
return () => {
root.removeAttribute("data-scroll");
};
}, [isBodyScrolled]);
useEffect(() => {
if (!isLoading && !isAuthenticated && !isPublicPage) {
router.replace("/auth/login");