style: apply prettier across the tree

One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:20:31 +02:00
1 parent b3fea2ef59
commit 1f4ba5939d
1000 files changed
+12130 -8385

No files matched your search

@@ -217,17 +217,12 @@ describe("<DailyBriefing>", () => {
});
it("renders the German connect-provider hint under the de locale", () => {
const html = render(
<DailyBriefing briefing={null} noProvider />,
"de",
);
const html = render(<DailyBriefing briefing={null} noProvider />, "de");
expect(html).toContain("Kein KI-Anbieter verbunden");
});
it("prefers the briefing content over the no-provider hint when both exist", () => {
const html = render(
<DailyBriefing briefing={baseBriefing} noProvider />,
);
const html = render(<DailyBriefing briefing={baseBriefing} noProvider />);
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
});
@@ -120,7 +120,9 @@ describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
// attribute order because additional ARIA attributes (e.g.
// `aria-labelledby`) may sit between `id` and `data-slot`.
const panelMatch = html.match(
new RegExp(`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`),
new RegExp(
`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`,
),
);
expect(panelMatch).not.toBeNull();
});
@@ -265,9 +267,7 @@ describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
delta={null}
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provisional-badge"',
);
expect(html).toContain('data-slot="health-score-card-provisional-badge"');
// EN badge copy.
expect(html).toContain("Provisional");
});
@@ -370,8 +370,6 @@ describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
initiallyExpanded
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provenance-footnote"',
);
expect(html).toContain('data-slot="health-score-card-provenance-footnote"');
});
});
@@ -208,7 +208,6 @@ describe("<HealthScoreCard>", () => {
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders German strings", () => {
const html = ssr(
<HealthScoreCard
@@ -274,9 +273,7 @@ describe("<HealthScoreCard>", () => {
delta={null}
/>,
);
const cardOpen = html.match(
/<div[^>]*data-slot="health-score-card"[^>]*>/,
);
const cardOpen = html.match(/<div[^>]*data-slot="health-score-card"[^>]*>/);
expect(cardOpen).not.toBeNull();
expect(cardOpen?.[0]).toContain("h-full");
expect(cardOpen?.[0]).toContain("flex");
@@ -106,9 +106,7 @@ describe("<HealthScoreDeltaExplainer>", () => {
// directly. Pinning the negative so a future refactor can't
// reintroduce the wrapper.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toMatch(
/<span[^>]*onclick/i,
);
expect(html).not.toMatch(/<span[^>]*onclick/i);
});
it("renders the trigger in German with the localised aria-label", () => {
@@ -129,9 +127,7 @@ describe("<HealthScoreDeltaExplainer>", () => {
// 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-body"');
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-title"',
);
@@ -187,15 +183,9 @@ describe("<HealthScoreDeltaExplainer>", () => {
// 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",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.body",
);
expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger");
expect(html).not.toContain("insights.healthScore.deltaExplainer.title");
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
}
});
});
@@ -42,7 +42,8 @@ vi.mock("@/hooks/use-insights-analytics", () => ({
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/hooks/use-insight-status")>();
const actual =
await importOriginal<typeof import("@/hooks/use-insight-status")>();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
@@ -113,7 +114,10 @@ describe("<HealthKitMetricPage> status mount", () => {
expect(html).toContain('data-testid="insight-status-card-loading"');
expect(html).not.toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
expect(metricStatusMock).toHaveBeenCalledWith(
"HEART_RATE_VARIABILITY",
true,
);
});
it("does not mount the assessment card when statusMetric is omitted", () => {
@@ -158,7 +162,10 @@ describe("<HealthKitMetricPage> status mount", () => {
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
// Stat-strip skeleton + chart skeleton paint; the assessment card never
@@ -180,7 +187,10 @@ describe("<HealthKitMetricPage> status mount", () => {
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
expect(html).toContain('data-slot="healthkit-metric-error"');
@@ -127,9 +127,7 @@ describe("<HeroStrip>", () => {
// the insights layout shell), not as a hero affordance.
it("does NOT render an ask-the-coach action button", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-coach"',
);
expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"');
});
// v1.4.25 W3 — the regenerate button moved from the hero action row
@@ -100,10 +100,7 @@ describe("<InsightStatusCard>", () => {
// upstream is unchanged; the card simply never announced it, so the
// assessment reads as authoritative.
const html = render(
<InsightStatusCard
{...baseProps}
text="Pulse stayed inside the band."
/>,
<InsightStatusCard {...baseProps} text="Pulse stayed inside the band." />,
);
expect(html).toContain("Pulse stayed inside the band.");
expect(html).not.toContain("Cached");
@@ -35,8 +35,7 @@ describe("<InsightsTabStrip> — v1.4.31 memo + availability-stability", () => {
// React.memo'd components carry the `$$typeof` Memo tag plus a
// `compare` field. Probing the type tag is the cheapest way to
// pin the memo contract without spinning a full React tree.
const tag = (InsightsTabStrip as unknown as { $$typeof: symbol })
.$$typeof;
const tag = (InsightsTabStrip as unknown as { $$typeof: symbol }).$$typeof;
expect(typeof tag).toBe("symbol");
expect(tag.toString()).toContain("memo");
});
@@ -270,12 +270,7 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
});
it("shows a pill when its slug is layout-visible AND has data", () => {
const visibleTileIds = new Set([
"overview",
"pulse",
"weight",
"bmi",
]);
const visibleTileIds = new Set(["overview", "pulse", "weight", "bmi"]);
const html = render(
<InsightsTabStrip
availability={dataAvailability}
@@ -369,9 +364,7 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
// when their data is present.
it("default layout shows every data-having pill (nav-pill regression guard)", () => {
const visibleTileIds = new Set(
DEFAULT_INSIGHTS_LAYOUT.tiles
.filter((t) => t.visible)
.map((t) => t.id),
DEFAULT_INSIGHTS_LAYOUT.tiles.filter((t) => t.visible).map((t) => t.id),
);
const availability: InsightInputs = {
summaries: {
@@ -470,9 +463,7 @@ describe("<InsightsTabStrip> — module enable/disable gate (v1.18.0)", () => {
});
it("hides the Recovery pill when the recovery module is disabled, keeps it when enabled", () => {
const disabled = render(
<InsightsTabStrip modules={{ recovery: false }} />,
);
const disabled = render(<InsightsTabStrip modules={{ recovery: false }} />);
expect(disabled).not.toContain(">Recovery<");
expect(disabled).not.toContain('href="/insights/recovery"');
// Overview still anchors the strip.
@@ -4,10 +4,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import {
SleepHypnogram,
type SleepHypnogramSession,
} from "../sleep-hypnogram";
import { SleepHypnogram, type SleepHypnogramSession } from "../sleep-hypnogram";
/**
* v1.16.8 — hypnogram polish: the night's measuring source rides the
@@ -105,9 +105,7 @@ describe("<TrendAnnotation>", () => {
annotation="Mood ran steady across the back half of the week — three good days, one dip, then a recovery. The dip aligns with the Tuesday BP spike but the magnitude was small. Logged five mood entries in seven days, well above the 30-day median of three."
/>,
);
expect(html).toMatch(
/<p[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
);
expect(html).toMatch(/<p[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/);
});
it("clamps the empty-state caption to 3 lines too (same row contract)", () => {
@@ -24,9 +24,7 @@ vi.mock("@/hooks/use-auth", () => ({
import { TrendDescriptorCaption } from "../trend-annotation";
function seededClient(
seed: (client: QueryClient) => void,
): QueryClient {
function seededClient(seed: (client: QueryClient) => void): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
@@ -34,7 +32,11 @@ function seededClient(
return client;
}
function render(client: QueryClient, node: React.ReactNode, locale: "en" | "de" = "en") {
function render(
client: QueryClient,
node: React.ReactNode,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
@@ -177,8 +177,7 @@ describe("<TrendsRow>", () => {
// flight. The loading flag now propagates to each annotation
// slot so the user reads a generating-state shimmer instead.
const html = render(<TrendsRow loading />);
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
const pending = html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
expect(html).not.toContain("Awaiting more data");
});
@@ -197,8 +196,7 @@ describe("<TrendsRow>", () => {
expect(html).not.toContain("stale BP");
expect(html).not.toContain("stale weight");
expect(html).not.toContain("stale mood");
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
const pending = html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
});
@@ -242,7 +240,9 @@ describe("<TrendsRow>", () => {
expect(html).toMatch(/data-metric="pulse"/);
expect(html).not.toMatch(/data-slot="trend-annotation-empty"/);
// The standard caption renders in its place.
expect(html).toMatch(/data-slot="trends-row-caption"[^>]*data-metric="pulse"/);
expect(html).toMatch(
/data-slot="trends-row-caption"[^>]*data-metric="pulse"/,
);
expect(html).toContain("Resting pulse over the last 30 days.");
});
@@ -259,8 +259,7 @@ describe("<TrendsRow>", () => {
expect(cards.length).toBe(3);
// None of these additive metrics carries an advisor annotation, so
// all three captions come through the standard description slot.
const captions =
html.match(/data-slot="trends-row-caption"/g) ?? [];
const captions = html.match(/data-slot="trends-row-caption"/g) ?? [];
expect(captions.length).toBe(3);
expect(html).toContain("Heart-rate variability over the last 30 days.");
expect(html).toContain("Daily step count over the last 30 days.");
@@ -14,9 +14,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
*/
vi.mock("@/lib/ai/schema", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/schema")>(
"@/lib/ai/schema",
);
const actual =
await vi.importActual<typeof import("@/lib/ai/schema")>("@/lib/ai/schema");
return actual;
});
@@ -84,10 +83,8 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
it("returns the interval while revalidating is true and under the cap", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MS,
} = await import("../use-insights-advisor");
const { nextAdvisorPollInterval, ADVISOR_REVALIDATE_POLL_MS } =
await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
});
@@ -99,10 +96,8 @@ describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
});
it("stops at the attempt ceiling even while still revalidating", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
} = await import("../use-insights-advisor");
const { nextAdvisorPollInterval, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS } =
await import("../use-insights-advisor");
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
).toBe(false);
@@ -498,7 +498,10 @@ describe("<MessageThread>", () => {
});
it("localises the remember control under 'de'", () => {
const html = render(<MessageThread conversation={baseConversation} />, "de");
const html = render(
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain('data-slot="coach-remember-message"');
expect(html).toContain("Merken");
});
@@ -707,7 +710,10 @@ describe("errorCodeToI18nKey", () => {
* messages, the optimistic user bubble, and the thread tail.
*/
describe("placeInterleaved", () => {
const item = (key: string, anchorAnswer: string | null): InterleavedThreadItem => ({
const item = (
key: string,
anchorAnswer: string | null,
): InterleavedThreadItem => ({
key,
anchorAnswer,
node: <span data-testid={key} />,
@@ -98,11 +98,7 @@ describe("<MobileRailTray>", () => {
// that contract so the carve-out stays gated on `historyOpen` /
// `sourcesOpen` and never paints orphan overlays.
const html = render(
<MobileRailTray
{...baseProps}
historyOpen={false}
sourcesOpen={false}
/>,
<MobileRailTray {...baseProps} historyOpen={false} sourcesOpen={false} />,
);
expect(html).not.toContain('data-slot="coach-drawer-history-tray"');
expect(html).not.toContain('data-slot="coach-drawer-sources-tray"');
@@ -265,14 +265,10 @@ export function CoachConversation({
current={item.current}
actionsDisabled={send.isStreaming || dismissQuestions.isPending}
onSkip={
item.current
? () => dispatchGuided({ type: "SKIP" })
: undefined
item.current ? () => dispatchGuided({ type: "SKIP" }) : undefined
}
onLater={
item.current
? () => dispatchGuided({ type: "EXIT" })
: undefined
item.current ? () => dispatchGuided({ type: "EXIT" }) : undefined
}
onDismissRemaining={
item.current
@@ -80,7 +80,9 @@ export function CoachDrawerBody({
return (
<div
data-slot="coach-drawer-body"
data-history-open={historyRail ? (historyOpen ? "true" : "false") : undefined}
data-history-open={
historyRail ? (historyOpen ? "true" : "false") : undefined
}
// v1.18.7 — the inline rail column animates between 0 and a fixed
// width so collapsing it hands the full width back to the thread
// without a layout jump. Without a rail the body is a single
@@ -130,9 +130,7 @@ type SpeechRecognitionLike = {
};
type SpeechRecognitionResultEventLike = {
resultIndex: number;
results: ArrayLike<
ArrayLike<{ transcript: string }> & { isFinal: boolean }
>;
results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }>;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
@@ -403,7 +401,7 @@ export function CoachInput({
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent] [scrollbar-width:thin]",
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
@@ -67,7 +67,8 @@ function ProgressDots({ current, total }: { current: number; total: number }) {
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
i + 1 === current && "ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 === current &&
"ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 > current && "bg-muted-foreground/25",
)}
/>
@@ -203,10 +204,7 @@ export function GuidedSummaryBubble({
const { t } = useTranslations();
return (
<div
data-slot="coach-guided-summary"
className="flex items-start gap-2.5"
>
<div data-slot="coach-guided-summary" className="flex items-start gap-2.5">
<GuidedAvatar icon={ClipboardCheck} />
<div
className={cn(
@@ -229,7 +227,7 @@ export function GuidedSummaryBubble({
data-slot="coach-guided-summary-link"
className={cn(
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"hover:underline focus-visible:ring-dracula-purple/40 rounded focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
@@ -184,8 +184,7 @@ export function HistoryRail({
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red",
isConfirming && "text-dracula-red hover:text-dracula-red",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
@@ -51,7 +51,10 @@ export function SelfContextAdoptOffer({
const adopt = useMutation({
mutationFn: async () => {
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { question, answer });
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
question,
answer,
});
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
@@ -300,9 +300,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}, []);
const send = useCallback(
async (
params: SendCoachMessageParams,
): Promise<string | null> => {
async (params: SendCoachMessageParams): Promise<string | null> => {
// Cancel any prior in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
+1 -4
View File
@@ -376,10 +376,7 @@ export function DailyBriefing({
>
{t("insights.dailyBriefing.signalsTitle")}
</p>
<div
data-slot="daily-briefing-signals"
className="space-y-2"
>
<div data-slot="daily-briefing-signals" className="space-y-2">
{briefing.signalsOfDay.map((signal, index) => (
<SignalRow
key={`${signal.sourceMetric}-${index}`}
@@ -42,10 +42,7 @@ describe("<ProvenanceExplainer>", () => {
it("meets the 44px hit-target floor on the trigger", () => {
const html = render(
<ProvenanceExplainer
provenance={PROVENANCE}
method="method copy"
/>,
<ProvenanceExplainer provenance={PROVENANCE} method="method copy" />,
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view";
import {
ScoreAnatomyView,
type AnatomyContributor,
} from "../score-anatomy-view";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -23,7 +23,12 @@ function ok(value: Record<string, unknown>): Resp {
metric: "X",
status: "ok",
value,
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
reason: null,
@@ -71,8 +76,19 @@ beforeEach(() => vi.clearAllMocks());
describe("<VitalsDashboard>", () => {
it("renders an available baseline vital as an ok tile", () => {
const batch = mockBatch((token) => {
if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") {
return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 });
if (
token.metric === "VITALS_BASELINE" &&
token.type === "RESTING_HEART_RATE"
) {
return ok({
type: "RESTING_HEART_RATE",
center: 55,
low: 48,
high: 62,
spread: 7,
sampleDays: 30,
k: 3,
});
}
return insufficient("no_readings_in_window");
});
@@ -96,7 +112,9 @@ describe("<VitalsDashboard>", () => {
});
it("renders a skeleton row (no tiles, no heading strand) while loading", () => {
const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
const batch = mockBatch(() => insufficient("no_readings_in_window"), {
isLoading: true,
});
const html = render(<VitalsDashboard batch={batch} />);
// Heading + grid present so the section reserves its final height, with a
// busy/live region and skeleton placeholders — but no real tiles yet.
@@ -108,7 +126,9 @@ describe("<VitalsDashboard>", () => {
});
it("shows an inline error + retry when the batch fails", () => {
const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
const batch = mockBatch(() => insufficient("no_readings_in_window"), {
isError: true,
});
const html = render(<VitalsDashboard batch={batch} />);
// The surface must not silently vanish — a compact error + a Retry button.
expect(html).toContain('data-slot="vitals-dashboard-error"');
@@ -135,7 +155,14 @@ describe("<VitalsDashboard>", () => {
it("renders the fitness-age tile with its age framing", () => {
const batch = mockBatch((token) => {
if (token.metric === "FITNESS_AGE") {
return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 });
return ok({
vo2Max: 46,
band: "green",
fitnessAgeDeltaYears: -6,
referenceBand: { low: 35, high: 45 },
trendDelta: 2,
readingCount: 4,
});
}
return insufficient("no_readings_in_window");
});
@@ -147,7 +174,13 @@ describe("<VitalsDashboard>", () => {
it("renders the BMI tile with its WHO category", () => {
const batch = mockBatch((token) => {
if (token.metric === "BMI") {
return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 });
return ok({
bmi: 24.7,
category: "normal",
band: "green",
weightKg: 80,
heightCm: 180,
});
}
return insufficient("no_readings_in_window");
});
@@ -210,7 +243,16 @@ describe("<VitalsDashboard>", () => {
it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => {
const batch = mockBatch((token) => {
if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") {
return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] });
return ok({
type: "STAIR_ASCENT_SPEED",
center: 0.4,
low: 0.3,
high: 0.5,
spread: 0.05,
sampleDays: 21,
k: 3,
series: [0.38, 0.4, 0.42],
});
}
return insufficient("no_readings_in_window");
});
@@ -21,7 +21,12 @@ function ok(value: Record<string, unknown>): Resp {
metric: "X",
status: "ok",
value,
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
reason: null,
@@ -33,16 +38,21 @@ function absent(): Resp {
metric: "X",
status: "insufficient",
value: null,
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [],
},
confidence: null,
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" },
reason: "no_readings_in_window",
};
}
function readFrom(map: Record<string, Resp>): <T>(
token: DerivedBatchToken,
) => DerivedMetricResponse<T> | null {
function readFrom(
map: Record<string, Resp>,
): <T>(token: DerivedBatchToken) => DerivedMetricResponse<T> | null {
return <T,>(token: DerivedBatchToken) =>
(map[token.metric] ?? absent()) as unknown as DerivedMetricResponse<T>;
}
@@ -99,11 +99,7 @@ export function CompositeScoreAnatomy({
// the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no
// cache row yet) renders nothing.
let anchorLine: string | null = null;
if (
metric === "STRAIN_SCORE" &&
data?.status === "ok" &&
data.value
) {
if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
const anchor = (data.value as WellnessScoreValue).anchor;
if (anchor === "personal") {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal");
@@ -238,10 +234,7 @@ export function CompositeScoreAnatomy({
/** Localised title per metric — composites use their existing copy keys;
* the persisted scores reuse the wellness-strip labels. */
function titleFor(
metric: AnatomyMetricId,
t: (key: string) => string,
): string {
function titleFor(metric: AnatomyMetricId, t: (key: string) => string): string {
switch (metric) {
case "SLEEP_SCORE":
case "READINESS":
@@ -123,7 +123,7 @@ export function CoverageMeter({
// 44px hit target via padding while the dots stay optically
// small; negative margin collapses the row back so the meter
// doesn't inflate its host row.
"inline-flex min-h-11 items-center gap-2 rounded -my-3 px-1 py-3",
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
className,
)}
@@ -162,7 +162,7 @@ export function ProvenanceExplainer({
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",
"-my-3 -mx-2",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
@@ -127,9 +127,7 @@ export function ScoreAnatomyView({
}
className={cn(
"relative flex flex-col gap-5 rounded-xl border p-5",
hue
? "wellness-detail-card"
: "border-border bg-card",
hue ? "wellness-detail-card" : "border-border bg-card",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
@@ -212,7 +210,8 @@ export function ScoreAnatomyView({
/** One ranked contributor row — label + impact bar + value + weight share. */
function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
const { t } = useTranslations();
const present = contributor.value != null && Number.isFinite(contributor.value);
const present =
contributor.value != null && Number.isFinite(contributor.value);
const clamped = present ? clampScore(contributor.value as number) : 0;
const rowBand: ScoreBand = bandForScore(clamped);
const weightPct = Math.round(contributor.weight * 100);
@@ -102,9 +102,7 @@ export function SparklineDeltaTile({
};
const sparkData =
series && series.length >= 2
? series.map((v, i) => ({ i, v }))
: null;
series && series.length >= 2 ? series.map((v, i) => ({ i, v })) : null;
// The sparkline tints with the trend sentiment so it reads "same signal"
// as the arrow; neutral metrics ride the muted-foreground line.
@@ -130,10 +128,7 @@ export function SparklineDeltaTile({
the old muted-uppercase caption. Kept on one line for the dense
grid; provenance trails to the right edge. */}
<div className="flex min-w-0 items-center gap-2">
<Icon
className="text-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<span
className="text-foreground min-w-0 flex-1 truncate text-base leading-none font-semibold whitespace-nowrap"
data-slot="sparkline-delta-tile-label"
@@ -184,7 +179,10 @@ export function SparklineDeltaTile({
than reserving an empty dashed placeholder (which read as visual
dead space across the whole grid). */}
{sparkData ? (
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
<div
className="mt-3 h-10 w-full"
data-slot="sparkline-delta-tile-spark"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={sparkData}
+128 -123
View File
@@ -35,142 +35,147 @@ export interface MetricProvenanceMeta {
* (extending the shape SLEEP_SCORE + READINESS already use) so the i18n
* bundle carries one consistent namespace.
*/
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> = {
VITALS_BASELINE: {
methodKey: "insights.derived.composite.VITALS_BASELINE.method",
standard: {
// Robust central-tendency / dispersion via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
{
VITALS_BASELINE: {
methodKey: "insights.derived.composite.VITALS_BASELINE.method",
standard: {
// Robust central-tendency / dispersion via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
},
},
HRV_BALANCE: {
methodKey: "insights.derived.composite.HRV_BALANCE.method",
standard: {
// Task Force of the ESC / NASPE — HRV standards of measurement.
name: "Task Force ESC/NASPE 1996, Circulation",
url: "https://doi.org/10.1161/01.CIR.93.5.1043",
HRV_BALANCE: {
methodKey: "insights.derived.composite.HRV_BALANCE.method",
standard: {
// Task Force of the ESC / NASPE — HRV standards of measurement.
name: "Task Force ESC/NASPE 1996, Circulation",
url: "https://doi.org/10.1161/01.CIR.93.5.1043",
},
},
},
BMI: {
methodKey: "insights.derived.composite.BMI.method",
standard: {
// WHO Technical Report Series 894 — BMI classification.
name: "WHO Technical Report Series 894",
url: "https://iris.who.int/handle/10665/42330",
BMI: {
methodKey: "insights.derived.composite.BMI.method",
standard: {
// WHO Technical Report Series 894 — BMI classification.
name: "WHO Technical Report Series 894",
url: "https://iris.who.int/handle/10665/42330",
},
},
},
FITNESS_AGE: {
methodKey: "insights.derived.composite.FITNESS_AGE.method",
standard: {
// FRIEND registry CRF reference standards (Kaminsky et al.).
name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
FITNESS_AGE: {
methodKey: "insights.derived.composite.FITNESS_AGE.method",
standard: {
// FRIEND registry CRF reference standards (Kaminsky et al.).
name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
},
},
},
VASCULAR_AGE_DELTA: {
methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
standard: {
// Reference values for arterial stiffness (PWV) collaboration.
name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
url: "https://doi.org/10.1093/eurheartj/ehq165",
VASCULAR_AGE_DELTA: {
methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
standard: {
// Reference values for arterial stiffness (PWV) collaboration.
name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
url: "https://doi.org/10.1093/eurheartj/ehq165",
},
caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
},
caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
},
COINCIDENT_DEVIATION: {
methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
standard: {
// Hampel-identifier / median ± k·MAD outlier flagging.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
COINCIDENT_DEVIATION: {
methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
standard: {
// Hampel-identifier / median ± k·MAD outlier flagging.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
},
caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
},
SLEEP_SCORE: {
methodKey: "insights.derived.composite.SLEEP_SCORE.method",
standard: {
name: "Hirshkowitz et al. 2015, Sleep Health",
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
SLEEP_SCORE: {
methodKey: "insights.derived.composite.SLEEP_SCORE.method",
standard: {
name: "Hirshkowitz et al. 2015, Sleep Health",
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
},
},
},
READINESS: {
methodKey: "insights.derived.composite.READINESS.method",
standard: {
name: "Plews et al. 2013, Sports Medicine",
url: "https://doi.org/10.1007/s40279-013-0071-8",
READINESS: {
methodKey: "insights.derived.composite.READINESS.method",
standard: {
name: "Plews et al. 2013, Sports Medicine",
url: "https://doi.org/10.1007/s40279-013-0071-8",
},
caveatKey: "insights.derived.composite.READINESS.caveat",
},
caveatKey: "insights.derived.composite.READINESS.caveat",
},
RECOVERY_SCORE: {
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
standard: {
// HRV-guided recovery / training-readiness framing.
name: "Buchheit 2014, Front. Physiol.",
url: "https://doi.org/10.3389/fphys.2014.00073",
RECOVERY_SCORE: {
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
standard: {
// HRV-guided recovery / training-readiness framing.
name: "Buchheit 2014, Front. Physiol.",
url: "https://doi.org/10.3389/fphys.2014.00073",
},
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
STRESS_SCORE: {
methodKey: "insights.derived.composite.STRESS_SCORE.method",
standard: {
// Neurovisceral-integration / HRV-as-stress-index basis.
name: "Kim et al. 2018, Psychiatry Investig.",
url: "https://doi.org/10.30773/pi.2017.08.17",
STRESS_SCORE: {
methodKey: "insights.derived.composite.STRESS_SCORE.method",
standard: {
// Neurovisceral-integration / HRV-as-stress-index basis.
name: "Kim et al. 2018, Psychiatry Investig.",
url: "https://doi.org/10.30773/pi.2017.08.17",
},
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
STRAIN_SCORE: {
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
standard: {
// Banister TRIMP training-load model (HR-reserve weighting).
name: "Banister 1991, TRIMP model",
url: "https://en.wikipedia.org/wiki/Training_Impulse",
STRAIN_SCORE: {
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
standard: {
// Banister TRIMP training-load model (HR-reserve weighting).
name: "Banister 1991, TRIMP model",
url: "https://en.wikipedia.org/wiki/Training_Impulse",
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
standard: {
// Robust personal-deviation band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
standard: {
// Robust personal-deviation band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
STAIR_ASCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
STAIR_ASCENT_SPEED_BASELINE: {
methodKey:
"insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey:
"insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
STAIR_DESCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
STAIR_DESCENT_SPEED_BASELINE: {
methodKey:
"insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey:
"insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
SIX_MINUTE_WALK_BAND: {
methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
standard: {
// Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
SIX_MINUTE_WALK_BAND: {
methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
standard: {
// Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
},
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
TRAJECTORY: {
methodKey: "insights.derived.composite.TRAJECTORY.method",
standard: {
// Ordinary least squares + the textbook OLS prediction interval.
name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
TRAJECTORY: {
methodKey: "insights.derived.composite.TRAJECTORY.method",
standard: {
// Ordinary least squares + the textbook OLS prediction interval.
name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
},
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
};
};
@@ -169,7 +169,9 @@ function CardSkeleton({ compact = false }: { compact?: boolean }) {
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
</div>
<div className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")} />
<div
className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")}
/>
</div>
);
}
@@ -211,7 +213,10 @@ export function TrajectoryForecastCard({
base: scaled(v.lastValue, valueScale),
range: 0,
};
return { points: [anchor, ...points], lastValue: scaled(v.lastValue, valueScale) };
return {
points: [anchor, ...points],
lastValue: scaled(v.lastValue, valueScale),
};
}, [data, valueScale]);
if (!data) {
@@ -258,7 +263,11 @@ export function TrajectoryForecastCard({
const v = data.value!;
const DirectionIcon =
v.direction === "up" ? TrendingUp : v.direction === "down" ? TrendingDown : Minus;
v.direction === "up"
? TrendingUp
: v.direction === "down"
? TrendingDown
: Minus;
const last = chart.points[chart.points.length - 1];
const projectedLow = last.base;
const projectedHigh = last.base + last.range;
@@ -2,10 +2,7 @@
import { useMemo } from "react";
import {
useDerivedBatch,
type DerivedBatchToken,
} from "./use-derived-metric";
import { useDerivedBatch, type DerivedBatchToken } from "./use-derived-metric";
// Type-only — keeps the derived registry's server graph out of the bundle.
import type { DerivedMetricId } from "@/lib/insights/derived/registry";
@@ -85,7 +85,10 @@ export function useDerivedMetric<T>(
try {
const params = new URLSearchParams({ metric });
if (type) params.set("type", type);
return apiGet<DerivedMetricResponse<T>>(`/api/insights/derived?${params.toString()}`, { signal: controller.signal });
return apiGet<DerivedMetricResponse<T>>(
`/api/insights/derived?${params.toString()}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
@@ -154,7 +157,10 @@ export function useDerivedBatch(
);
try {
const params = new URLSearchParams({ metrics: wireTokens.join(",") });
return apiGet<DerivedBatchResponse>(`/api/insights/derived/batch?${params.toString()}`, { signal: controller.signal });
return apiGet<DerivedBatchResponse>(
`/api/insights/derived/batch?${params.toString()}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
@@ -23,10 +23,7 @@ import {
SECTION_MOBILITY,
type DashboardDerived,
} from "./use-dashboard-derived";
import {
resolveTileLayout,
type InsightsLayout,
} from "@/lib/insights-layout";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
@@ -153,7 +150,10 @@ const MOBILITY_TILE_LAYOUT_ID: Record<string, string> = {
* `null` layout (caller passed none) is treated as everything-visible so the
* default behaviour is unchanged.
*/
function tileVisible(layout: InsightsLayout | undefined, tileId: string): boolean {
function tileVisible(
layout: InsightsLayout | undefined,
tileId: string,
): boolean {
if (!layout) return true;
return resolveTileLayout(layout, tileId).visible;
}
@@ -653,23 +653,23 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
order: tileOrder(layout, "bmi"),
node: <BmiTile key="bmi" {...tileProps} />,
},
...SECTION_VITALS.filter(
(type) => type !== "HEART_RATE_VARIABILITY",
).map((type) => {
const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
),
};
}),
...SECTION_VITALS.filter((type) => type !== "HEART_RATE_VARIABILITY").map(
(type) => {
const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
),
};
},
),
];
return entries
.filter((e) => tileVisible(layout, e.id))
@@ -156,8 +156,10 @@ export function DeviceScoreTile({
);
}
export interface ConnectedDeviceScoreTileProps
extends Omit<DeviceScoreTileProps, "summary"> {
export interface ConnectedDeviceScoreTileProps extends Omit<
DeviceScoreTileProps,
"summary"
> {
/** Optional section heading rendered above the tile when it has data. */
sectionTitle?: string;
/** Optional Lucide glyph for the section heading. */
@@ -187,9 +189,7 @@ export function ConnectedDeviceScoreTile({
if ((summary?.count ?? 0) === 0) return null;
const tile = (
<DeviceScoreTile type={type} summary={summary} {...tileProps} />
);
const tile = <DeviceScoreTile type={type} summary={summary} {...tileProps} />;
if (!sectionTitle || !sectionIcon) return tile;
@@ -73,7 +73,9 @@ describe("<GlucoseClinicalPanel> learning state", () => {
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
mgdl,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW });
const glucoseClinical = computeGlucoseClinicalMetrics(readings, {
now: NOW,
});
expect(glucoseClinical.stillLearning).toBe(true);
analyticsMock.mockReturnValue({
@@ -85,7 +87,7 @@ describe("<GlucoseClinicalPanel> learning state", () => {
expect(html).toContain('data-state="learning"');
expect(html).toContain("Still learning your glucose");
// the asserted blocks must NOT be present in the learning state
expect(html).not.toContain("data-slot=\"glucose-tir-bar\"");
expect(html).not.toContain('data-slot="glucose-tir-bar"');
expect(html).not.toContain("Glucose Management Indicator");
// the spot-reading caveat is always carried
expect(html).toContain("spot-reading estimates");
@@ -115,7 +117,10 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 });
return computeGlucoseClinicalMetrics(readings, {
now: NOW,
windowDays: 30,
});
}
it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => {
@@ -140,7 +145,7 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
expect(html).toContain('data-slot="glucose-cv-badge"');
// advanced disclosure trigger is present (region collapsed by default in SSR)
expect(html).toContain('data-slot="glucose-advanced-toggle"');
expect(html).toContain("aria-expanded=\"false\"");
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("J-index");
});
@@ -169,7 +174,10 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
});
it("renders nothing when the thick slice carries no glucoseClinical", () => {
analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } });
analyticsMock.mockReturnValue({
isLoading: false,
data: { summaries: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toBe("");
});
@@ -26,7 +26,8 @@ import type { DataSummary } from "@/lib/analytics/trends";
const CONTEXT_LABEL_KEYS: Record<string, string> = {
FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting",
POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
POSTPRANDIAL:
"insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
};
@@ -68,10 +69,7 @@ export function GlucoseAdvancedDisclosure({
<span>{t("insights.bloodGlucose.clinical.advanced.toggle")}</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
open && "rotate-180",
)}
className={cn("h-4 w-4 transition-transform", open && "rotate-180")}
/>
</button>
@@ -159,7 +159,10 @@ export function GlucoseClinicalPanel() {
) : null}
{/* Headline metrics */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-slot="glucose-headline">
<section
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
data-slot="glucose-headline"
>
{clinical.meanMgdl !== null ? (
<Metric
icon={<Droplet className="h-3.5 w-3.5" aria-hidden="true" />}
@@ -217,7 +220,10 @@ export function GlucoseClinicalPanel() {
and reports `isSpotEstimate: false`, so the caveat would undersell
the data. */}
{clinical.isSpotEstimate ? (
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
<p
className="text-muted-foreground/80 text-xs"
data-slot="glucose-spot-caveat"
>
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
) : null}
@@ -41,11 +41,31 @@ export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
const veryHigh = distribution.tarLevel2;
const segments: Array<{ key: BandKey; label: string; fraction: number }> = [
{ key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow },
{ key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low },
{ key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange },
{ key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high },
{ key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh },
{
key: "veryLow",
label: t("insights.bloodGlucose.clinical.tir.veryLow"),
fraction: veryLow,
},
{
key: "low",
label: t("insights.bloodGlucose.clinical.tir.low"),
fraction: low,
},
{
key: "inRange",
label: t("insights.bloodGlucose.clinical.tir.inRange"),
fraction: inRange,
},
{
key: "high",
label: t("insights.bloodGlucose.clinical.tir.high"),
fraction: high,
},
{
key: "veryHigh",
label: t("insights.bloodGlucose.clinical.tir.veryHigh"),
fraction: veryHigh,
},
];
const pct = (f: number) => Math.round(f * 100);
@@ -91,7 +111,7 @@ export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
/>
{s.label}
</span>
<span className="text-foreground tabular-nums font-medium">
<span className="text-foreground font-medium tabular-nums">
{pct(s.fraction)}%
</span>
</li>
+7 -14
View File
@@ -228,8 +228,9 @@ export function HealthScoreCard({
// descending so the biggest contributor sits first. Components with
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
const provenanceRows = COMPONENT_ORDER
.filter((key) => moodEnabled || key !== "mood")
const provenanceRows = COMPONENT_ORDER.filter(
(key) => moodEnabled || key !== "mood",
)
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
@@ -244,10 +245,7 @@ export function HealthScoreCard({
effective: c.weight * (c.value !== null ? 1 : 0),
};
})
.sort(
(a, b) =>
b.effective - a.effective || a.key.localeCompare(b.key),
);
.sort((a, b) => b.effective - a.effective || a.key.localeCompare(b.key));
const presentCount = provenanceRows.filter((r) => r.value !== null).length;
const totalCount = provenanceRows.length;
@@ -273,7 +271,7 @@ export function HealthScoreCard({
// gutter). The basis values still bias the column generous
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
// narrows.
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
// v1.4.28 R3c-Insights — stretch to match the parent's
// `items-stretch` row height (FB-H1/H2). `h-full` + an inner
// flex column lets the disclaimer ride to the bottom with
@@ -377,10 +375,7 @@ export function HealthScoreCard({
) : (
<>
{delta > 0 && (
<ArrowUp
className="text-success h-3 w-3"
aria-hidden="true"
/>
<ArrowUp className="text-success h-3 w-3" aria-hidden="true" />
)}
{delta < 0 && (
<ArrowDown
@@ -580,9 +575,7 @@ export function HealthScoreCard({
<div
className={cn(
"h-full",
isEmpty
? "bg-muted"
: "bg-info/60",
isEmpty ? "bg-muted" : "bg-info/60",
)}
style={{
width: isEmpty
@@ -105,7 +105,7 @@ export function HealthScoreDeltaExplainer({
// 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",
"-my-3 -mx-2",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
@@ -123,10 +123,9 @@ export function HealthScoreDeltaExplainer({
open={open}
onOpenChange={setOpen}
title={title}
description={t(
"insights.healthScore.deltaExplainer.description",
{ delta: delta > 0 ? `+${delta}` : `${delta}` },
)}
description={t("insights.healthScore.deltaExplainer.description", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
>
<p
id={bodyId}
@@ -254,8 +254,7 @@ export function HealthKitMetricPage({
min: rawSummary.min === null ? null : rawSummary.min * scale,
max: rawSummary.max === null ? null : rawSummary.max * scale,
mean: rawSummary.mean === null ? null : rawSummary.mean * scale,
median:
rawSummary.median === null ? null : rawSummary.median * scale,
median: rawSummary.median === null ? null : rawSummary.median * scale,
}
: rawSummary;
+1 -1
View File
@@ -230,7 +230,7 @@ export function HeroStrip({
aria-hidden="true"
className={cn(
"bg-card/65 border-border/60 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
"flex h-full min-h-64 flex-col gap-3.5",
)}
>
@@ -116,10 +116,10 @@ export function InsightsEditMode({
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
sections: next.sections,
tiles: next.tiles,
});
version: 2,
sections: next.sections,
tiles: next.tiles,
});
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
@@ -115,8 +115,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// `.planning/research/v15-insights-blocking-bug.md` fix 2.
const summaries = analyticsQuery.data?.summaries;
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
const hasMedication =
(comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
@@ -138,7 +137,9 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const visibleTileIds = useMemo(() => {
if (!layoutSettled) return undefined;
return new Set(
insightsLayout.tiles.filter((tile) => tile.visible).map((tile) => tile.id),
insightsLayout.tiles
.filter((tile) => tile.visible)
.map((tile) => tile.id),
);
}, [layoutSettled, insightsLayout]);
const tileOrder = useMemo(() => {
+1 -5
View File
@@ -254,11 +254,7 @@ export function LayoutCoachFab() {
</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"
>
<span data-slot="coach-fab-live" aria-live="polite" className="sr-only">
{liveAnnouncement}
</span>
</>
@@ -102,7 +102,7 @@ export function MeasurementDiversityNudge({
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -my-3 -mx-2 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Lightbulb className="size-4" aria-hidden="true" />
</button>
@@ -62,7 +62,9 @@ export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
// The query is gated by the hook on `isAuthenticated`, and disabled
// entirely when this slug owns no correlation so a target-less metric
// never fires the thick fetch.
const { data } = useAnalyticsQuery({ enabled: key != null ? undefined : false });
const { data } = useAnalyticsQuery({
enabled: key != null ? undefined : false,
});
const analytics = data as AnalyticsWithCorrelations | undefined;
if (!flags.correlations || !key) return null;
@@ -335,69 +335,69 @@ function TargetReferencePanel({
</CardHeader>
<CardContent className="space-y-3">
{/* Row 2: range bar — where today's value sits inside the band. */}
{target.current != null ? (
<RangeBar
value={target.current}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
) : null}
{/* Row 2b: diastolic range bar for blood pressure. */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
{target.current != null ? (
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
value={target.current}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
</div>
) : null}
) : null}
{/* Row 3: 30-day average (blood pressure only — every other metric
{/* Row 2b: diastolic range bar for blood pressure. */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
) : null}
{/* Row 3: 30-day average (blood pressure only — every other metric
surfaces its 30-day average in `<MetricPrimaryTile>` above the
chart, so repeating it here would duplicate the figure). */}
{averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
<span>{averageLabel}</span>
</div>
) : null}
{averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
<span>{averageLabel}</span>
</div>
) : null}
{/* Row 4: 7-day consistency strip. */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : null}
{/* Row 4: 7-day consistency strip. */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : null}
{/* Footer: guideline source link. The adjust-target affordance
{/* Footer: guideline source link. The adjust-target affordance
moved to the header gear (see `TargetAdjustProvider`); this
panel registers itself as that gear's edit target above. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
</div>
</CardContent>
</Card>
@@ -2,10 +2,7 @@ import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
MoodBetterDays,
type MoodBetterDayFactor,
} from "../mood-better-days";
import { MoodBetterDays, type MoodBetterDayFactor } from "../mood-better-days";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
@@ -100,16 +97,14 @@ describe("<MoodBetterDays>", () => {
});
it("renders the description once and no local generic disclaimer", () => {
const html = render(
<MoodBetterDays factors={[tagFactor, metricFactor]} />,
);
const html = render(<MoodBetterDays factors={[tagFactor, metricFactor]} />);
expect((html.match(/ranked by how strongly/g) ?? []).length).toBe(1);
// The generic "associations only, not causes" disclaimer now lives once
// in the page-level Insights footer, not on this board.
expect(html).not.toContain("Associations only");
// one row per factor
expect((html.match(/data-slot="mood-better-day-factor"/g) ?? []).length).toBe(
2,
);
expect(
(html.match(/data-slot="mood-better-day-factor"/g) ?? []).length,
).toBe(2);
});
});
@@ -3,10 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const COMPONENT_DIR = join(__dirname, "..");
const MOOD_LIST = join(
__dirname,
"../../../mood/mood-list.tsx",
);
const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx");
/**
* v1.8.6 — the notes timeline display is removed from the mood insights
@@ -102,9 +102,7 @@ export function MoodBetterDays({
// read-out is COLORED TEXT, so it needs the Alucard light-mode
// override to clear AA. The arrow icon shares the same token so the
// good→down ramp stays coherent across both themes.
const directionColor = up
? "var(--success)"
: "var(--destructive)";
const directionColor = up ? "var(--success)" : "var(--destructive)";
// The effect read-out: tag factors show the mood-point delta,
// metric factors show the correlation coefficient.
@@ -58,7 +58,10 @@ export function MoodDistributionChart({
// tidy card rather than a dominant block, and matches the weekday sibling.
<div className="h-[150px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
<BarChart
data={data}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<XAxis
dataKey="label"
// v1.15.14 — theme-aware axis text. `--dracula-fg` (near-white) is
@@ -385,7 +385,6 @@ export function MoodInsightsSections({
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
@@ -34,7 +34,10 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
<ul className="space-y-2" data-slot="mood-tag-breakdown">
{tags.map((row) => (
<li key={row.tag} className="flex items-center gap-2 text-sm">
<span className="text-foreground w-28 shrink-0 truncate" title={row.tag}>
<span
className="text-foreground w-28 shrink-0 truncate"
title={row.tag}
>
{row.tag}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
@@ -60,11 +60,7 @@ function deltaColor(delta: number): string {
return delta >= 0 ? "var(--success)" : "var(--destructive)";
}
export function MoodTagInfluence({
rows,
}: {
rows: MoodTagInfluenceRow[];
}) {
export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
const { t } = useTranslations();
if (rows.length === 0) return null;
@@ -73,8 +69,7 @@ export function MoodTagInfluence({
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
const label =
row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const label = row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
return (
@@ -103,9 +103,7 @@ export function MoodTagMetricCrosstab({
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out
// is COLORED TEXT, so it needs the Alucard light-mode override for AA.
const deltaColor = up
? "var(--success)"
: "var(--destructive)";
const deltaColor = up ? "var(--success)" : "var(--destructive)";
return (
<li
key={`${row.metricKey}:${row.tag}`}
@@ -66,7 +66,10 @@ export function MoodWhatStandsOut({
return (
<Card data-slot="mood-what-stands-out">
<CardHeader className="pb-2">
<TileHeader icon={TrendingUp} title={t("insights.mood.narrative.title")} />
<TileHeader
icon={TrendingUp}
title={t("insights.mood.narrative.title")}
/>
</CardHeader>
<CardContent className="space-y-4">
{hasNarratives && <MoodNarrativeFeed items={narratives} />}
@@ -104,7 +104,8 @@ export function PersonalRecordBadge({
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
row.metricSlot === null && isPRWithin30Days(row.achievedAt, dataUpdatedAt),
row.metricSlot === null &&
isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
@@ -119,7 +120,7 @@ export function PersonalRecordBadge({
// green on the dark card (#50fa7b). Both clear 4.5:1 against
// their card background — the previous `text-dracula-green` on
// `bg-dracula-green/10` measured ~2.6:1 in dark mode.
"border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide tabular-nums uppercase",
"border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide uppercase tabular-nums",
className,
)}
aria-label={t("insights.personalRecord.badge")}
@@ -30,10 +30,8 @@ import { ConfidenceMeter } from "./confidence-meter";
const SEVERITY_BADGE_STYLES: Record<string, string> = {
info: "bg-info/10 text-info border border-info/25",
suggestion:
"bg-dose-accent/10 text-dose-accent border border-dose-accent/25",
important:
"bg-warning/10 text-warning border border-warning/25",
suggestion: "bg-dose-accent/10 text-dose-accent border border-dose-accent/25",
important: "bg-warning/10 text-warning border border-warning/25",
urgent: "bg-destructive/10 text-destructive border border-destructive/25",
};
@@ -233,15 +233,9 @@ export function RecommendationFeedback({
data-slot="rec-feedback-already-rated"
>
{verdict === "up" ? (
<ThumbsUp
className="text-success h-3.5 w-3.5"
aria-hidden="true"
/>
<ThumbsUp className="text-success h-3.5 w-3.5" aria-hidden="true" />
) : (
<ThumbsDown
className="text-warning h-3.5 w-3.5"
aria-hidden="true"
/>
<ThumbsDown className="text-warning h-3.5 w-3.5" aria-hidden="true" />
)}
<span data-feedback-already-rated={verdict}>
{t("insights.recommendation.feedbackAlreadyRated")}
@@ -266,7 +260,10 @@ export function RecommendationFeedback({
className="text-muted-foreground hover:text-success disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingUp ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
)}
@@ -281,7 +278,10 @@ export function RecommendationFeedback({
className="text-muted-foreground hover:text-warning disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingDown ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
@@ -68,9 +68,9 @@ export function RecoverySection() {
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode =
((analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null)
?.restMode ?? null) as RestModeAnnotation | null;
const restMode = ((
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
)?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
const unitScore = t("insights.deviceScore.unitScore");
+3 -1
View File
@@ -43,7 +43,9 @@ export function RestModeBanner({
// start).
const since = annotation.since;
const sinceLabel =
since && !Number.isNaN(Date.parse(since)) ? fmt.date(new Date(since)) : null;
since && !Number.isNaN(Date.parse(since))
? fmt.date(new Date(since))
: null;
return (
<div
@@ -17,7 +17,10 @@ function clockLabel(minutesOfDay: number): string {
}
/** Whole hours + minutes from a minute total, for the social-jetlag readout. */
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
function hoursMinutes(totalMinutes: number): {
hours: number;
minutes: number;
} {
const t = Math.round(totalMinutes);
return { hours: Math.floor(t / 60), minutes: t - Math.floor(t / 60) * 60 };
}
@@ -37,8 +40,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const [showAdvanced, setShowAdvanced] = useState(false);
if (chronotype.state === "learning" || chronotype.band == null) {
const need =
chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
const need = chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
return (
<Card data-slot="chronotype-card">
<CardHeader className="pb-0">
@@ -35,7 +35,10 @@ export function ChronotypeSection({ enabled }: { enabled: boolean }) {
return (
<section data-slot="chronotype-section" className="space-y-3">
<SectionHeading icon={Clock} title={t("insights.sleep.chronotype.title")} />
<SectionHeading
icon={Clock}
title={t("insights.sleep.chronotype.title")}
/>
{isError ? (
<div
data-slot="chronotype-error"
@@ -8,7 +8,10 @@ import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
function hoursMinutes(totalMinutes: number): {
hours: number;
minutes: number;
} {
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes - hours * 60);
return { hours, minutes };
@@ -39,8 +39,12 @@ function clauseFor(
finding: SleepQualityFinding,
t: (key: string, vars?: Record<string, string | number>) => string,
): string {
const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`);
const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`);
const metricLabel = t(
`insights.sleepQuality.assessment.metric.${finding.type}`,
);
const gradeLabel = t(
`insights.sleepQuality.assessment.grade.${finding.grade}`,
);
// Efficiency / performance / consistency are percentages; the headline score
// is a 0–100 index. Both read naturally as a rounded integer.
const value = Math.round(finding.value);
@@ -189,9 +189,7 @@ function SleepQualityAssessmentBlock({
// which branch renders — it runs at the top of this component — so swapping
// its render for the note loses no `hasProvider` knowledge.
if (showGroundedNote) {
return (
<SleepQualityGroundedNote summaries={summaries} showWhenAiAbsent />
);
return <SleepQualityGroundedNote summaries={summaries} showWhenAiAbsent />;
}
return (
+1 -1
View File
@@ -315,7 +315,7 @@ export function SubPageShell({
data-slot="metric-reference-guidance"
className={cn(
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
"align-middle focus-visible:ring-ring/50 rounded-full",
"focus-visible:ring-ring/50 rounded-full align-middle",
"focus-visible:ring-2 focus-visible:outline-none",
)}
aria-label={t(
@@ -201,9 +201,8 @@ async function fetchAdvisor(
// Lazy schema load — see the type-only import note at the top. The
// module is cached after the first call, so this await is free from
// the second fetch on.
const { dailyBriefingSchema, trendAnnotationsSchema } = await import(
"@/lib/ai/schema"
);
const { dailyBriefingSchema, trendAnnotationsSchema } =
await import("@/lib/ai/schema");
// The cached `insights` blob may carry a `dailyBriefing` from a fresh
// PROMPT_VERSION 4.20.x generation. Lift it onto the payload so
// consumers don't have to know the legacy shape.
+22 -11
View File
@@ -75,14 +75,22 @@ function formatDistanceKm(meters: number, locale: string): string {
}).format(km);
}
function formatNumber(value: number, locale: string, fractionDigits = 0): string {
function formatNumber(
value: number,
locale: string,
fractionDigits = 0,
): string {
return new Intl.NumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
}).format(value);
}
function formatPaceMinPerKm(durationSec: number, meters: number, locale: string): string {
function formatPaceMinPerKm(
durationSec: number,
meters: number,
locale: string,
): string {
// Pace is the seconds-per-kilometre value. Only meaningful for
// run / walk / hike. The caller already gated on `meters > 0`.
const secPerKm = (durationSec / meters) * 1000;
@@ -91,7 +99,11 @@ function formatPaceMinPerKm(durationSec: number, meters: number, locale: string)
return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`;
}
function formatDateRange(startedAt: string, endedAt: string, locale: string): string {
function formatDateRange(
startedAt: string,
endedAt: string,
locale: string,
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
const dateFmt = new Intl.DateTimeFormat(locale, {
@@ -139,7 +151,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
>
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
@@ -262,12 +274,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
// it when the workout has both a distance and a sport with a
// canonical pace unit; the fallback covers the long-tail HK enum
// values without lighting up a misleading number.
const PACE_SPORTS = new Set([
"walking",
"running",
"hiking",
"cycling",
]);
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
if (
workout.distanceM != null &&
workout.distanceM > 100 &&
@@ -401,7 +408,11 @@ export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
// ── Heart-rate chart ─────────────────────────────────────────────────
export function WorkoutDetailHRChart({ workout }: { workout: WorkoutDetailPayload }) {
export function WorkoutDetailHRChart({
workout,
}: {
workout: WorkoutDetailPayload;
}) {
const { t } = useTranslations();
// Per-second HR samples are not persisted on the Workout row today —
+4 -5
View File
@@ -98,7 +98,7 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
return (
<ul
data-slot="workout-list"
className={cn("divide-y divide-border rounded-lg border", className)}
className={cn("divide-border divide-y rounded-lg border", className)}
>
{workouts.map((workout) => {
const Icon = iconForSport(workout.sportType);
@@ -107,9 +107,8 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
// Translation keys missing from one locale fall back to the
// canonical sport-type string so a new HK enum value never
// renders the raw `insights.workouts.sport.x` placeholder.
const sportName = sportLabel === sportLabelKey
? workout.sportType
: sportLabel;
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
@@ -123,7 +122,7 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
>
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-4" />
</span>