mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: W1 insights surface (i-icon, subtitle, steps, type-click) into v1.22
This commit is contained in:
commit
7d3c528157
22 files changed
+345
-562
No files matched your search
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Vorherige Seite",
|
||||
"nextPage": "Nächste Seite",
|
||||
"dailyTotalCaption": "({count} Einzelwerte)",
|
||||
"openMetricInsights": "{type}-Auswertung öffnen",
|
||||
"sleepNapCaption": "+{count} Nickerchen",
|
||||
"sleepAwakeningsCaption": "{count} Wachphasen",
|
||||
"expandDay": "Einzelwerte dieses Tages anzeigen",
|
||||
@@ -3240,6 +3241,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Deine Vitalwerte",
|
||||
"sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline",
|
||||
"loadingLabel": "Vitalwerte werden geladen",
|
||||
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
|
||||
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
|
||||
@@ -3366,6 +3368,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Deine Gesundheitswerte",
|
||||
"sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten",
|
||||
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"dailyTotalCaption": "({count} samples)",
|
||||
"openMetricInsights": "Open {type} insights",
|
||||
"sleepNapCaption": "+{count} nap(s)",
|
||||
"sleepAwakeningsCaption": "{count} awakening(s)",
|
||||
"expandDay": "Show samples for this day",
|
||||
@@ -3240,6 +3241,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Your vitals",
|
||||
"sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline",
|
||||
"loadingLabel": "Loading your vitals",
|
||||
"loadError": "Could not load your vitals right now.",
|
||||
"building": "Building your typical range — {count} of {target} days",
|
||||
@@ -3366,6 +3368,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Your health scores",
|
||||
"sectionSubtitle": "Composite recovery, stress and strain read from your connected devices",
|
||||
"loadError": "Could not load your wellness scores right now.",
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
|
||||
@@ -198,8 +198,8 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
// At most amber — never destructive/red.
|
||||
expect(html).toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
// The provenance affordance reaches the card.
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
// The provenance method reaches the card.
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
// The state change is announced politely to SR users (not assertively).
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
|
||||
@@ -129,10 +129,11 @@ describe("<HealthScoreCard>", () => {
|
||||
});
|
||||
|
||||
it("threads aria-describedby from the delta text to the explainer body", () => {
|
||||
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the
|
||||
// explainer trigger's aria-controls reads the same id. Both
|
||||
// attributes share one `useId()` thread so SR can connect
|
||||
// "−3 vs last week" to the three-sentence read.
|
||||
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the inline
|
||||
// explainer caption carries the same id. v1.22 — the read no longer
|
||||
// hides behind a "?" trigger (no aria-controls); the body renders inline
|
||||
// and the shared `useId()` thread still connects "−3 vs last week" to the
|
||||
// three-sentence read.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
@@ -148,8 +149,12 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(deltaSpan).not.toBeNull();
|
||||
const threadedId = deltaSpan?.[1] ?? "";
|
||||
expect(threadedId).not.toBe("");
|
||||
// The same id sits on the trigger button's aria-controls.
|
||||
expect(html).toContain(`aria-controls="${threadedId}"`);
|
||||
// The same id sits on the inline explainer body.
|
||||
expect(html).toMatch(
|
||||
new RegExp(
|
||||
`<span[^>]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders four component rows with their values", () => {
|
||||
|
||||
@@ -4,16 +4,15 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — FB-I1 — the "?" affordance next to the delta
|
||||
* line opens a 3-sentence read of which components contributed, what
|
||||
* the comparison window is, and one concrete next step.
|
||||
* v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a
|
||||
* three-sentence read of which components contributed, what the comparison
|
||||
* window is, and one concrete next step.
|
||||
*
|
||||
* Tests run through SSR so they don't depend on a browser surface.
|
||||
* The popover / sheet body mounts on click on the live page; the
|
||||
* SSR snapshot captures the trigger button (always present) plus a
|
||||
* locale-grounded check that the body copy actually exists in the
|
||||
* translation payload — the load-bearing constraint is "every
|
||||
* locale ships the 3-sentence read", not the popover open animation.
|
||||
* v1.22 — the read no longer hides behind a "?" glyph + popover; it renders
|
||||
* inline as a muted caption beside the delta line. These tests run through
|
||||
* SSR (no browser surface) and pin: the inline body paints by default, it
|
||||
* threads the parent-supplied id for aria-describedby, and every locale
|
||||
* ships the body copy.
|
||||
*/
|
||||
|
||||
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
@@ -23,114 +22,42 @@ function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that paints the explainer body via the same `t()` call the
|
||||
* popover uses. If a future refactor drops a locale key the test
|
||||
* fails at the `t()` call site rather than silently rendering the
|
||||
* fallback chevron.
|
||||
* Helper that paints the explainer body via the same `t()` call the caption
|
||||
* uses. If a future refactor drops a locale key the test fails at the `t()`
|
||||
* call site rather than silently rendering the literal key path.
|
||||
*/
|
||||
function BodyProbe() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div>
|
||||
<span data-slot="probe-trigger">
|
||||
{t("insights.healthScore.deltaExplainer.trigger")}
|
||||
</span>
|
||||
<span data-slot="probe-title">
|
||||
{t("insights.healthScore.deltaExplainer.title")}
|
||||
</span>
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
</div>
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HealthScoreDeltaExplainer>", () => {
|
||||
it("renders the icon-only trigger button with an accessible label (EN)", () => {
|
||||
it("renders the read inline as a muted caption (no trigger glyph)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
expect(html).toContain('data-slot="health-score-delta-explainer-body"');
|
||||
// The old icon-only disclosure trigger is gone.
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-trigger"',
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
|
||||
});
|
||||
|
||||
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
|
||||
// The glyph stays 12 px but the click surface inflates to 44 px
|
||||
// via `min-h-11 min-w-11` on the trigger; the parent row keeps
|
||||
// its 16 px stride because `-my-3 -mx-2` swallows the extra
|
||||
// reach. Pinning so a future refactor can't drop back to the
|
||||
// visual-chip-as-hit-target shape that failed WCAG 2.5.5.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain("min-h-11");
|
||||
expect(trigger?.[0]).toContain("min-w-11");
|
||||
});
|
||||
|
||||
it("threads aria-expanded + aria-controls onto the trigger button", () => {
|
||||
// SR users get a clear "this opens a disclosure" cue via
|
||||
// aria-expanded; the aria-controls value is the same id the
|
||||
// body paints so the SR can jump to the explanation after
|
||||
// the user activates the trigger.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain("aria-expanded");
|
||||
expect(trigger?.[0]).toContain("aria-controls");
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
});
|
||||
|
||||
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
|
||||
// Parent owns the id and paints aria-describedby on the delta
|
||||
// span; the explainer re-uses the same id for its
|
||||
// aria-controls thread. The shared id is the seam SR uses to
|
||||
// jump from the digit to the explanation.
|
||||
const html = ssr(
|
||||
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
|
||||
);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/,
|
||||
);
|
||||
expect(trigger?.[0]).toContain('aria-controls="parent-id-x"');
|
||||
});
|
||||
|
||||
it("does not wrap the trigger in a clickable <span> (single interactive element)", () => {
|
||||
// The earlier mobile path wrapped the button in a
|
||||
// `<span onClick onKeyDown>` which created two interactive
|
||||
// elements in the a11y tree and intercepted clicks on the 2 px
|
||||
// gap around the button. The button now owns the open toggle
|
||||
// directly. Pinning the negative so a future refactor can't
|
||||
// reintroduce the wrapper.
|
||||
it("paints the body copy on the initial SSR snapshot (always visible)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).not.toMatch(/<span[^>]*onclick/i);
|
||||
});
|
||||
|
||||
it("renders the trigger in German with the localised aria-label", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain(
|
||||
'aria-label="Was bedeutet diese Veränderung?"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
|
||||
// Closed-by-default contract: the trigger button is the only
|
||||
// surface the SSR snapshot owns; the body lives in a portal that
|
||||
// mounts after the user taps. Pinning the negative side keeps a
|
||||
// future "open on render" refactor from leaking long-form copy
|
||||
// into the static markup.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).not.toContain('data-slot="health-score-delta-explainer-body"');
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-title"',
|
||||
);
|
||||
expect(html).toContain("BP, weight, mood and medication adherence");
|
||||
});
|
||||
|
||||
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
|
||||
@@ -171,20 +98,18 @@ describe("<HealthScoreDeltaExplainer>", () => {
|
||||
expect(text).toContain("vor einer Woche");
|
||||
});
|
||||
|
||||
it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => {
|
||||
// Pinning every locale stops a future feature branch from
|
||||
// shipping an English fallback for the new explainer keys.
|
||||
it("ships the body key in every locale supported by R3c-Insights", () => {
|
||||
// Pinning every locale stops a future feature branch from shipping an
|
||||
// English fallback for the explainer body key.
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The probe re-uses the same `t()` payload the popover does,
|
||||
// so any missing key surfaces as the literal key path which
|
||||
// would never contain a space.
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger");
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.title");
|
||||
// The probe re-uses the same `t()` payload the caption does, so any
|
||||
// missing key surfaces as the literal key path which never contains a
|
||||
// space.
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ const EMPTY_PROVENANCE: DerivedProvenance = {
|
||||
};
|
||||
|
||||
describe("<ProvenanceExplainer>", () => {
|
||||
it("renders an accessible trigger that controls the body (collapsed by default)", () => {
|
||||
it("renders the method inline as a muted caption (no trigger glyph)", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
@@ -34,28 +34,21 @@ describe("<ProvenanceExplainer>", () => {
|
||||
bodyId="prov-1"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('aria-controls="prov-1"');
|
||||
expect(html).toContain("aria-label");
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
expect(html).toContain("BMI is weight divided by height squared.");
|
||||
expect(html).toContain('id="prov-1"');
|
||||
// The old icon-only disclosure trigger is gone.
|
||||
expect(html).not.toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("meets the 44px hit-target floor on the trigger", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer provenance={PROVENANCE} method="method copy" />,
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
|
||||
it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => {
|
||||
it("renders the empty-inputs / bad-date provenance without throwing", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={EMPTY_PROVENANCE}
|
||||
method="method copy"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
});
|
||||
|
||||
it("accepts a cited standard prop without erroring", () => {
|
||||
@@ -69,6 +62,6 @@ describe("<ProvenanceExplainer>", () => {
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
});
|
||||
});
|
||||
@@ -55,9 +55,9 @@ describe("<ScoreAnatomyView>", () => {
|
||||
// The missing contributor renders present=false (dropped, not blank).
|
||||
expect(html).toContain('data-contributor="respiratory"');
|
||||
expect(html).toContain('data-present="false"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
// v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed;
|
||||
// the non-clinical framing now lives behind the provenance explainer.
|
||||
// the non-clinical framing now lives in the inline provenance method.
|
||||
expect(html).not.toContain('data-slot="score-anatomy-disclaimer"');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the "how this is computed / standard cited" surface.
|
||||
* v1.10.0 — the "how this is computed" surface.
|
||||
*
|
||||
* A collapsible `ⓘ` explainer that renders, as React text children (NO
|
||||
* markdown library — the XSS rule), the metric's inputs, a plain-language
|
||||
* method, and an optional link to the cited standard (IEEE / RFC / WHO /
|
||||
* LOINC / Wikipedia). Reads the `provenance` field off `Derived<T>` for the
|
||||
* inputs chip + the "as of" + window framing.
|
||||
*
|
||||
* Surface follows the shipped `health-score-delta-explainer` pattern:
|
||||
* Popover on `md+`, bottom-sheet on phone-class viewports via
|
||||
* `ResponsiveSheet`. Owns its own open state. aria-describedby threads the
|
||||
* trigger to the body.
|
||||
* v1.22 — the collapsible `ⓘ` glyph that gated the method/inputs/window read
|
||||
* behind a popover / bottom-sheet is removed; the maintainer wants the
|
||||
* trailing info affordance gone across the insights surface. The
|
||||
* plain-language method now renders inline as a muted caption (React text
|
||||
* children only — the XSS rule still forbids a markdown library), so the
|
||||
* "how computed" line is always visible rather than disclosure-only. The
|
||||
* `provenance`, `standard` and `title` props are retained for callers; the
|
||||
* inputs-chip / window / cited-standard detail folded out with the popover.
|
||||
*/
|
||||
|
||||
export interface ProvenanceStandard {
|
||||
@@ -43,167 +31,31 @@ export interface ProvenanceExplainerProps {
|
||||
* passes a localised string; the component never parses markup.
|
||||
*/
|
||||
method: React.ReactNode;
|
||||
/** Optional cited standard rendered as an external link in the footer. */
|
||||
/** Optional cited standard. Retained for callers. */
|
||||
standard?: ProvenanceStandard;
|
||||
/** Accessible title of the surface (sheet header / popover first line). */
|
||||
/** Accessible title of the surface. Retained for callers. */
|
||||
title?: string;
|
||||
/** Optional className for the trigger button. */
|
||||
/** Optional className for the caption. */
|
||||
className?: string;
|
||||
/** Optional id the parent threads as aria-describedby on its own element. */
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function ProvenanceExplainer({
|
||||
provenance,
|
||||
method,
|
||||
standard,
|
||||
title,
|
||||
className,
|
||||
bodyId,
|
||||
}: ProvenanceExplainerProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
|
||||
const resolvedTitle = title ?? t("insights.derived.provenance.title");
|
||||
const triggerLabel = t("insights.derived.provenance.trigger");
|
||||
|
||||
const asOf = (() => {
|
||||
const parsed = new Date(provenance.computedAt);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
}).format(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const body = (
|
||||
<div
|
||||
id={resolvedBodyId}
|
||||
data-slot="provenance-explainer-body"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div data-slot="provenance-explainer-inputs">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.inputsLabel")}
|
||||
</p>
|
||||
{provenance.inputs.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{provenance.inputs.map((input) => (
|
||||
<li
|
||||
key={input}
|
||||
data-slot="provenance-explainer-input-chip"
|
||||
className="border-info/40 text-info inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none"
|
||||
>
|
||||
{input}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||
{t("insights.derived.provenance.noInputs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-slot="provenance-explainer-method">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.methodLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-[11px] leading-snug">
|
||||
{method}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-slot="provenance-explainer-window"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.derived.provenance.window", {
|
||||
days: provenance.windowDays,
|
||||
source: provenance.source,
|
||||
})}
|
||||
{asOf
|
||||
? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{standard && (
|
||||
<a
|
||||
data-slot="provenance-explainer-standard-link"
|
||||
href={standard.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.derived.provenance.standardLink", {
|
||||
name: standard.name,
|
||||
})}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="provenance-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
return (
|
||||
<span
|
||||
id={bodyId}
|
||||
data-slot="provenance-explainer-method"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
"text-muted-foreground text-[11px] leading-snug",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
<div className="text-sm">{body}</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="provenance-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-2"
|
||||
>
|
||||
<p
|
||||
data-slot="provenance-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{resolvedTitle}
|
||||
</p>
|
||||
{body}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{method}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -729,6 +729,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
<SectionHeading
|
||||
icon={HeartPulse}
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-dashboard-error"
|
||||
@@ -774,6 +775,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
<SectionHeading
|
||||
icon={HeartPulse}
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
|
||||
@@ -211,6 +211,7 @@ export function WellnessScores({
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
subtitle={t("insights.derived.scores.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="wellness-scores-error"
|
||||
@@ -394,6 +395,7 @@ export function WellnessScores({
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
subtitle={t("insights.derived.scores.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="wellness-scores-grid"
|
||||
|
||||
@@ -1,167 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useId } from "react";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
|
||||
* v1.4.28 R3c-Insights — explain the "vs last week" delta.
|
||||
*
|
||||
* FB-I1: the user asked why the headline number moved. The earlier
|
||||
* delta line surfaced the digit ("-3 vs last week") without any
|
||||
* mention of which components shifted, what the comparison window
|
||||
* actually is, or what the user can do about it. The explainer is a
|
||||
* single icon-only `?` button next to the delta line that opens a
|
||||
* three-sentence read.
|
||||
* v1.22 — the icon-only `?` glyph that gated the three-sentence read behind
|
||||
* a popover / bottom-sheet is removed; the maintainer wants the trailing
|
||||
* help affordance gone across the insights surface. The descriptive read
|
||||
* now renders inline as a muted caption beside the delta line, so it is
|
||||
* always visible rather than disclosure-only. The body keeps its id so the
|
||||
* parent's `aria-describedby` on the delta digit still threads to it.
|
||||
*
|
||||
* Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet
|
||||
* on phone-class viewports. Both share the same body copy via the
|
||||
* `insights.healthScore.deltaExplainer.body` key; the title is
|
||||
* surfaced as the popover-content first line on desktop and as the
|
||||
* sheet header on mobile (the ResponsiveSheet primitive already
|
||||
* paints a header band on the sheet branch).
|
||||
*
|
||||
* Pure presentational. Owns its own open state — the parent doesn't
|
||||
* need to thread an extra prop through.
|
||||
* Operator suppression is preserved: when `healthScoreExplainer` is off the
|
||||
* caption does not render (the parent delta digit stays visible because the
|
||||
* parent paints it directly).
|
||||
*/
|
||||
|
||||
interface HealthScoreDeltaExplainerProps {
|
||||
/**
|
||||
* The delta value the parent already painted on the line above. The
|
||||
* explainer doesn't repaint the digit; it sits next to the line
|
||||
* with the `?` glyph and lets the user request the prose.
|
||||
*/
|
||||
/** The delta value the parent paints on the line above. Retained for callers. */
|
||||
delta: number;
|
||||
/**
|
||||
* Optional className for the trigger button so the parent can
|
||||
* align the glyph to the delta line's baseline.
|
||||
*/
|
||||
/** Optional className for the caption so the parent can tune its spacing. */
|
||||
className?: string;
|
||||
/**
|
||||
* Optional id the parent owns and threads to the explainer body via
|
||||
* the popover/sheet markup. The same id sits on the parent's delta
|
||||
* `<span>` as `aria-describedby` so screen readers can connect "−3
|
||||
* vs last week" to the three-sentence read on demand.
|
||||
* Optional id the parent owns and threads to the caption. The same id sits
|
||||
* on the parent's delta `<span>` as `aria-describedby` so screen readers
|
||||
* connect the digit to the read.
|
||||
*/
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function HealthScoreDeltaExplainer({
|
||||
delta,
|
||||
className,
|
||||
bodyId,
|
||||
}: HealthScoreDeltaExplainerProps) {
|
||||
const { t } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const flags = useFeatureFlags();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Stable fallback id when the parent doesn't supply one. The body
|
||||
// still paints the id so future consumers can thread the same
|
||||
// describedby pattern without modifying the explainer.
|
||||
// Stable fallback id when the parent doesn't supply one. The hook sits
|
||||
// above the conditional return so the hook order stays stable.
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
// v1.4.31 — operator can hide the `?` trigger; the delta digit on
|
||||
// the parent line stays visible because the parent reads it
|
||||
// directly. Silent suppression per the architecture brief. The
|
||||
// early return sits below every hook call so the hook order stays
|
||||
// stable across renders.
|
||||
|
||||
// v1.4.31 — operator can hide the read; silent suppression per the
|
||||
// architecture brief.
|
||||
if (!flags.healthScoreExplainer) return null;
|
||||
|
||||
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
|
||||
const title = t("insights.healthScore.deltaExplainer.title");
|
||||
const body = t("insights.healthScore.deltaExplainer.body");
|
||||
|
||||
// Direct click handler on the button on both branches. Earlier
|
||||
// mobile path wrapped the button in a `<span onClick onKeyDown>`
|
||||
// which created two interactive elements in the a11y tree and
|
||||
// intercepted clicks on the 2 px gap around the button. The button
|
||||
// already handles Enter/Space natively; the only thing the wrapper
|
||||
// owned was the open toggle, which moves onto the button itself.
|
||||
const handleOpen = () => setOpen(true);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="health-score-delta-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
return (
|
||||
<span
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className={cn(
|
||||
// Visible glyph stays 12 px so the chip reads at the delta
|
||||
// line's height without inflating the row. The button itself
|
||||
// sits at the 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`;
|
||||
// negative `-my-3 -mx-2` collapses the surrounding row back
|
||||
// to its 16 px stride so the optical chip stays small while
|
||||
// the hit surface lifts to 44 px. Transparent padding owns
|
||||
// the extra reach.
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
"text-muted-foreground text-[11px] leading-snug",
|
||||
className,
|
||||
)}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
description={t("insights.healthScore.deltaExplainer.description", {
|
||||
delta: delta > 0 ? `+${delta}` : `${delta}`,
|
||||
})}
|
||||
>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="health-score-delta-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p
|
||||
data-slot="health-score-delta-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -69,10 +69,11 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
// factor label resolves to the localized measurement name
|
||||
expect(html).toContain("Time in Daylight");
|
||||
expect(html).toContain("higher next-day mood");
|
||||
// The n/r/q detail rides a focusable explainer icon (its tooltip content
|
||||
// is portalled on open, so it is absent from the static markup). Assert
|
||||
// the icon is wired per pair instead of the inline stat string.
|
||||
expect(html).toContain('aria-label="How this was computed"');
|
||||
// v1.22 — the n/r/q detail no longer hides behind an "i" glyph + tooltip;
|
||||
// it reads inline as a muted caption, so the stat string is present in the
|
||||
// static markup.
|
||||
expect(html).toContain('data-slot="mood-explainer-detail"');
|
||||
expect(html).toContain("40 paired days");
|
||||
});
|
||||
|
||||
it("phrases a mood → next-day outcome pair", () => {
|
||||
@@ -85,7 +86,7 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
expect(html).toContain("lower next-day");
|
||||
});
|
||||
|
||||
it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => {
|
||||
it("filters to mood pairs and reads the full-family caveat inline", () => {
|
||||
const html = renderWith(
|
||||
[
|
||||
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
|
||||
@@ -96,14 +97,12 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
|
||||
1,
|
||||
);
|
||||
// The false-discovery footer + the observational disclaimer fold into the
|
||||
// subsection explainer icon; its tooltip content is portalled on open, so
|
||||
// assert the trigger exists rather than the (now hidden) footer string.
|
||||
// v1.22 — the false-discovery footer + observational disclaimer no longer
|
||||
// hide behind an "i" glyph + tooltip; they read inline as a muted caption,
|
||||
// so both the caption slot and its text are present in the static markup.
|
||||
expect(
|
||||
(html.match(/aria-label="How this was computed"/g) ?? []).length,
|
||||
(html.match(/data-slot="mood-explainer-detail"/g) ?? []).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
// The former muted explainer paragraph + inline footnote rows are gone.
|
||||
expect(html).not.toContain("day-to-day pairs tested");
|
||||
expect(html).not.toContain("hold up after correcting");
|
||||
expect(html).toContain("day-to-day pairs tested");
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.12.4 (C3/C4) — compact explainer affordance for the mood surface.
|
||||
* v1.12.4 (C3/C4) — compact explainer for the mood surface.
|
||||
*
|
||||
* Replaces the low-density inline "how this was computed" text rows
|
||||
* (paired-day counts, the false-discovery footer, the observational
|
||||
* disclaimer) with a single ring/info icon. The detail moves into a
|
||||
* hover/focus tooltip so the surface stops spending a full-width row on a
|
||||
* footnote. The trigger is a real focusable `button` carrying an
|
||||
* `aria-label`, so the explanation is reachable by keyboard and screen
|
||||
* readers — not hover-only. Self-contained `TooltipProvider` so callers
|
||||
* don't need an ambient one.
|
||||
* v1.22 — the round info glyph that hid the statistical caveat behind a
|
||||
* hover/focus tooltip is gone. The maintainer wants the trailing "i"
|
||||
* affordance removed across the insights surface; the descriptive text it
|
||||
* used to gate now reads inline as a muted caption, so the explanation is
|
||||
* always visible rather than disclosure-only. The `label` prop is retained
|
||||
* for callers but is no longer rendered as an accessible trigger name —
|
||||
* there is no trigger to name.
|
||||
*/
|
||||
export function MoodExplainerIcon({
|
||||
label,
|
||||
detail,
|
||||
className,
|
||||
}: {
|
||||
/** Accessible name for the trigger (what the icon explains). */
|
||||
/** Accessible name for the (now removed) trigger. Retained for callers. */
|
||||
label: string;
|
||||
/** The explanation shown in the tooltip. */
|
||||
/** The explanation, rendered inline as a muted caption. */
|
||||
detail: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-current/40 transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="size-3" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
|
||||
{detail}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span
|
||||
data-slot="mood-explainer-detail"
|
||||
className={cn("text-muted-foreground text-xs leading-snug", className)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarRange, Info } from "lucide-react";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { AskCoachAction } from "@/components/insights/ask-coach-action";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
@@ -64,6 +59,13 @@ async function fetchNarrative(
|
||||
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 — the round ⓘ provenance glyph that hid the "how this was computed"
|
||||
* read behind a popover is removed; the maintainer wants the trailing info
|
||||
* affordance gone across the insights surface. The method line — and, when
|
||||
* present, the metrics it drew on — now reads inline as a muted caption at the
|
||||
* foot of the card, always visible rather than disclosure-only.
|
||||
*/
|
||||
function ProvenanceDisclosure({
|
||||
provenance,
|
||||
}: {
|
||||
@@ -72,27 +74,15 @@ function ProvenanceDisclosure({
|
||||
const { t } = useTranslations();
|
||||
const metrics = provenance.metrics.slice(0, 8).join(", ");
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
aria-label={t("insights.narrativeProvenanceLabel")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex size-5 shrink-0 items-center justify-center rounded-full transition-colors"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm" align="end">
|
||||
<p className="text-foreground font-medium">
|
||||
{t("insights.narrativeProvenanceLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("insights.narrativeProvenanceMethod")}
|
||||
</p>
|
||||
{metrics ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("insights.narrativeProvenanceMetrics", { metrics })}
|
||||
</p>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p
|
||||
data-slot="period-narrative-provenance"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.narrativeProvenanceMethod")}
|
||||
{metrics
|
||||
? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}`
|
||||
: ""}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,11 +158,6 @@ export function PeriodNarrativeCard({
|
||||
<SectionHeading
|
||||
icon={CalendarRange}
|
||||
title={t("insights.narrativeTitle")}
|
||||
action={
|
||||
narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div
|
||||
data-slot="period-narrative-card"
|
||||
@@ -215,6 +200,10 @@ export function PeriodNarrativeCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
) : null}
|
||||
|
||||
{narrative ? (
|
||||
<p className="text-muted-foreground text-right text-[11px]">
|
||||
{data?.revalidating
|
||||
|
||||
@@ -34,6 +34,14 @@ interface SectionHeadingProps {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Heading text rendered inside the `<h2>`. */
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Optional one-line description rendered directly under the `<h2>`, in the
|
||||
* same muted typography the detail pages use beneath their headings
|
||||
* (`text-muted-foreground text-sm`). Opt-in per section so the overview can
|
||||
* carry the "descriptive line under every heading" rule without forcing a
|
||||
* subtitle where none reads well.
|
||||
*/
|
||||
subtitle?: ReactNode;
|
||||
/** Optional trailing affordance pinned to the right edge of the row. */
|
||||
action?: ReactNode;
|
||||
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
|
||||
@@ -44,6 +52,7 @@ interface SectionHeadingProps {
|
||||
export function SectionHeading({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
id,
|
||||
className,
|
||||
@@ -58,9 +67,19 @@ export function SectionHeading({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="min-w-0">
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p
|
||||
data-slot="section-heading-subtitle"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{action}</div>
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Info, ListOrdered } from "lucide-react";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
|
||||
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
|
||||
import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope";
|
||||
@@ -344,33 +338,6 @@ export function SubPageShell({
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
|
||||
{/* v1.18.6 — a discreet general-guidance tooltip travels with
|
||||
every metric definition. The reference bands in the body
|
||||
above are population anchors, not medical advice; this
|
||||
caption pins that framing without crowding the prose. */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="metric-reference-guidance"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
|
||||
"focus-visible:ring-ring/50 rounded-full align-middle",
|
||||
"focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
aria-label={t(
|
||||
"insights.subPage.explainer.referenceGuidance",
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-pretty">
|
||||
{t("insights.subPage.explainer.referenceGuidance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
|
||||
@@ -200,11 +200,7 @@ export function TrendsRow({
|
||||
<SectionHeading
|
||||
icon={TrendingUp}
|
||||
title={t("insights.trendsRow.title")}
|
||||
action={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.trendsRow.subtitle")}
|
||||
</p>
|
||||
}
|
||||
subtitle={t("insights.trendsRow.subtitle")}
|
||||
/>
|
||||
{/* v1.4.22 A4 — equal-height cards. The annotation prose
|
||||
below each chart varies in length, which used to leave the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -90,6 +91,10 @@ import {
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
subPageSlugForType,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7c — cumulative HK types whose list view collapses to one
|
||||
@@ -277,6 +282,20 @@ export function MeasurementList({
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
// v1.22 — clicking a row's type badge drills into that metric's Insights
|
||||
// sub-page when one exists, else into the filtered measurements list. The
|
||||
// reverse map prefers the dedicated single-metric slug (PULSE → `pulse`,
|
||||
// WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep
|
||||
// link the page already supports.
|
||||
const navigateForType = (type: string) => {
|
||||
const slug = subPageSlugForType(type);
|
||||
router.push(
|
||||
slug
|
||||
? `${INSIGHTS_OVERVIEW_PATH}/${slug}`
|
||||
: `/measurements?type=${encodeURIComponent(type)}`,
|
||||
);
|
||||
};
|
||||
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
|
||||
// and the type selector is hidden; the filter state seeds from it.
|
||||
// v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
|
||||
@@ -922,12 +941,26 @@ export function MeasurementList({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
asChild
|
||||
variant="secondary"
|
||||
className={TYPE_COLORS[m.type] ?? ""}
|
||||
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t(
|
||||
"measurements.openMetricInsights",
|
||||
{
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
</button>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
@@ -1111,11 +1144,18 @@ export function MeasurementList({
|
||||
</div>
|
||||
)}
|
||||
{Icon && (
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t("measurements.openMetricInsights", {
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
})}
|
||||
className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/* v1.4.27 MB7 / CF-76 — bump the metadata
|
||||
|
||||
@@ -76,6 +76,14 @@ describe("resolveInsightsLayout — v1 → v2 forward upgrade", () => {
|
||||
visible: false,
|
||||
order: 1,
|
||||
});
|
||||
|
||||
// v1.22 — a late-added default tile the saved blob never enumerated
|
||||
// (e.g. `steps`, which entered the set in v1.12) merges in
|
||||
// default-VISIBLE, matching the fresh-account default. The old
|
||||
// force-hidden merge silently dropped its nav pill on every read of an
|
||||
// existing or iOS-synced layout.
|
||||
const steps = resolved.tiles.find((t) => t.id === "steps");
|
||||
expect(steps?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the full default layout for garbage input", () => {
|
||||
|
||||
@@ -215,7 +215,14 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
|
||||
});
|
||||
|
||||
// Merge with defaults so new tiles introduced in later versions show
|
||||
// up automatically (invisible by default, the user opts in).
|
||||
// up automatically. v1.22 — appended tiles inherit the default's
|
||||
// visibility (`visible: true` for every default tile) instead of being
|
||||
// force-hidden. The old default-invisible override silently dropped the
|
||||
// pill for any late-added tile id (e.g. `steps`, which entered the set in
|
||||
// v1.12) on every read of an existing or iOS-synced layout that didn't
|
||||
// enumerate it, so it never matched the fresh-account default. The
|
||||
// per-tile data floor (`summaries[...].count > 0` at the call site) still
|
||||
// keeps an empty metric from bloating the nav.
|
||||
const savedIds = new Set(filtered.map((t) => t.id));
|
||||
const missing = DEFAULT_INSIGHTS_LAYOUT.tiles.filter(
|
||||
(t) => !savedIds.has(t.id),
|
||||
@@ -223,7 +230,6 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
|
||||
const maxOrder = Math.max(0, ...filtered.map((t) => t.order));
|
||||
const appended = missing.map((t, i) => ({
|
||||
...t,
|
||||
visible: false, // default-invisible on auto-upgrade
|
||||
order: maxOrder + 1 + i,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
TYPE_TO_SUB_PAGE_SLUG,
|
||||
subPageSlugForType,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
|
||||
/**
|
||||
* v1.22 — the measurements list lets a user click a metric type to drill
|
||||
* into its Insights sub-page. The reverse map underneath must resolve an
|
||||
* ambiguous type to its dedicated single-metric page, never a multi-metric
|
||||
* cluster page, and must stay silent for types with no card so the caller
|
||||
* falls back to the filtered list.
|
||||
*/
|
||||
describe("type → insights sub-page slug", () => {
|
||||
it("prefers the dedicated single-metric page over a multi-metric one", () => {
|
||||
// PULSE lives on both `pulse` (=[PULSE]) and `blood-pressure`
|
||||
// (=[SYS, DIA, PULSE]) — the single-metric page wins.
|
||||
expect(subPageSlugForType("PULSE")).toBe("pulse");
|
||||
// WEIGHT lives on both `weight` (=[WEIGHT]) and the derived `bmi`
|
||||
// (=[WEIGHT]) — the first single-metric page in slug order wins.
|
||||
expect(subPageSlugForType("WEIGHT")).toBe("weight");
|
||||
});
|
||||
|
||||
it("falls back to the multi-metric page for its component types", () => {
|
||||
expect(subPageSlugForType("BLOOD_PRESSURE_SYS")).toBe("blood-pressure");
|
||||
expect(subPageSlugForType("BLOOD_PRESSURE_DIA")).toBe("blood-pressure");
|
||||
// Both HRV flavours only appear on the `hrv` page.
|
||||
expect(subPageSlugForType("HEART_RATE_VARIABILITY")).toBe("hrv");
|
||||
expect(subPageSlugForType("HRV_RMSSD")).toBe("hrv");
|
||||
});
|
||||
|
||||
it("maps a plain single-metric type to its page", () => {
|
||||
expect(subPageSlugForType("ACTIVITY_STEPS")).toBe("steps");
|
||||
expect(subPageSlugForType("MOOD")).toBe("mood");
|
||||
expect(subPageSlugForType("BLOOD_GLUCOSE")).toBe("blood-glucose");
|
||||
});
|
||||
|
||||
it("returns undefined for a type with no sub-page", () => {
|
||||
expect(subPageSlugForType("BODY_FAT")).toBeUndefined();
|
||||
expect(subPageSlugForType("RECOVERY_SCORE")).toBeUndefined();
|
||||
expect(subPageSlugForType("NOT_A_REAL_TYPE")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never resolves a type to an empty-metric (event-driven) page", () => {
|
||||
// `workouts` and `medications` carry no measurement series, so no type
|
||||
// should ever resolve to them.
|
||||
for (const slug of Object.values(TYPE_TO_SUB_PAGE_SLUG)) {
|
||||
expect(slug).not.toBe("workouts");
|
||||
expect(slug).not.toBe("medications");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -321,6 +321,52 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record<
|
||||
]),
|
||||
) as unknown as Record<ManagerGroup, readonly SubPageSlug[]>;
|
||||
|
||||
/**
|
||||
* v1.22 — reverse lookup: a measurement type → the routed insights sub-page
|
||||
* slug that focuses it, when one exists.
|
||||
*
|
||||
* Built from {@link SUB_PAGE_METRIC}, preferring the slug whose metric list
|
||||
* is exactly `[type]` so an ambiguous type resolves to its dedicated
|
||||
* single-metric page rather than a multi-metric cluster: `PULSE` → `pulse`
|
||||
* (not `blood-pressure`), `WEIGHT` → `weight` (not the derived `bmi`). When
|
||||
* two single-metric pages list the same type (`weight` + `bmi` both list
|
||||
* `WEIGHT`) the first in `SUB_PAGE_SLUGS` order wins (`weight`). A type that
|
||||
* only ever appears on a multi-metric page (the two blood-pressure
|
||||
* components, the two HRV flavours) falls back to that page. Types with no
|
||||
* sub-page at all (BODY_FAT, RECOVERY_SCORE, the `*_EVENT` series, most
|
||||
* device scores) are absent — the caller falls back to the filtered
|
||||
* measurements list.
|
||||
*/
|
||||
export const TYPE_TO_SUB_PAGE_SLUG: Readonly<Record<string, SubPageSlug>> =
|
||||
(() => {
|
||||
const map: Record<string, SubPageSlug> = {};
|
||||
// Pass 1 — exact single-metric pages win (first occurrence in slug order).
|
||||
for (const slug of SUB_PAGE_SLUGS) {
|
||||
const metrics = SUB_PAGE_METRIC[slug];
|
||||
if (metrics.length !== 1) continue;
|
||||
const type = metrics[0];
|
||||
if (!(type in map)) map[type] = slug;
|
||||
}
|
||||
// Pass 2 — multi-metric pages fill any type a single-metric page misses.
|
||||
for (const slug of SUB_PAGE_SLUGS) {
|
||||
const metrics = SUB_PAGE_METRIC[slug];
|
||||
if (metrics.length === 1) continue;
|
||||
for (const type of metrics) {
|
||||
if (!(type in map)) map[type] = slug;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
/**
|
||||
* The insights sub-page slug for a measurement type, or `undefined` when no
|
||||
* routed card focuses it. See {@link TYPE_TO_SUB_PAGE_SLUG} for the
|
||||
* ambiguity-resolution rule.
|
||||
*/
|
||||
export function subPageSlugForType(type: string): SubPageSlug | undefined {
|
||||
return TYPE_TO_SUB_PAGE_SLUG[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mother-page route. Kept here as a named constant so call sites
|
||||
* never have to hard-code the string.
|
||||
|
||||
Reference in new issue
Block a user