mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user