mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
b13721cc97
commit
107a111545
13 files changed
+237
-21
No files matched your search
+2
-1
@@ -1624,7 +1624,8 @@
|
||||
"30d": "ggü. vorherigen 30T",
|
||||
"90d": "ggü. vorherigen 90T",
|
||||
"1y": "ggü. Vorjahr"
|
||||
}
|
||||
},
|
||||
"noChange": "keine Änderung"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Zusammenhänge",
|
||||
|
||||
+2
-1
@@ -1624,7 +1624,8 @@
|
||||
"30d": "vs prior 30d",
|
||||
"90d": "vs prior 90d",
|
||||
"1y": "vs prior year"
|
||||
}
|
||||
},
|
||||
"noChange": "no change"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -1624,7 +1624,8 @@
|
||||
"30d": "vs 30g precedenti",
|
||||
"90d": "vs 90g precedenti",
|
||||
"1y": "vs anno precedente"
|
||||
}
|
||||
},
|
||||
"noChange": "nessuna variazione"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Correlazioni",
|
||||
|
||||
+2
-1
@@ -1624,7 +1624,8 @@
|
||||
"30d": "vs poprzednie 30d",
|
||||
"90d": "vs poprzednie 90d",
|
||||
"1y": "vs poprzedni rok"
|
||||
}
|
||||
},
|
||||
"noChange": "bez zmian"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Korelacje",
|
||||
|
||||
+14
-9
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user