mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(insights): follow every score over time with one range selector
The health score had no history anywhere, readiness and the sleep score showed a fixed sparkline, and only the stored nightly scores offered the range tabs. Every score page now charts its course with the same 7 / 30 / 90 / All tabs, remembered per page, and each point opens its day. - GET /api/insights/score-history?score=&days= serves the daily course of the health score (its stored day rows), readiness (the nightly blend, filed on its wake day) and the sleep score (each night against the trailing window ending on it), so a point equals what the day view shows for that day. MANAGE record read, module-gated for readiness and sleep, days 1 to 3650, additive. - The health score's line breaks where its recipe changed (configuration or algorithm version); the usual range is formed from the newest side of the last seam. - The range tabs move into one shared component used by every chart; recovery, stress and strain keep their measurement chart, now with the range remembered and no overlay dropdown. - New page /insights/health-score, linked from the score panel and from the day view's score tile.
This commit is contained in:
1 parent
1d080ee166
commit
62140a4aee
38 files changed
+2437
-183
No files matched your search
@@ -101,6 +101,20 @@ describe("<HealthChart mini>", () => {
|
||||
expect(html).toMatch(/data-slot="chart-range-tab"/);
|
||||
});
|
||||
|
||||
it("keeps the range tabs but drops the overlay dropdown with overlayControls off", () => {
|
||||
// v1.42 — the score histories bind a key to remember their range only.
|
||||
const html = render(
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
title="Score"
|
||||
chartKey="scoreRecovery"
|
||||
overlayControls={false}
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/data-slot="chart-range-tab"/g)).toHaveLength(4);
|
||||
expect(html).not.toContain("chart-overlay-controls-trigger");
|
||||
});
|
||||
|
||||
it("renders the overlay-controls trigger when chartKey is supplied", () => {
|
||||
const html = render(
|
||||
<HealthChart types={["WEIGHT"]} title="Weight" chartKey="weight" />,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChartRangePoints } from "@/lib/dashboard-layout";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The range tabs every chart drawn in days carries: 7 / 30 / 90 days and
|
||||
* All. One component, so the metric charts and the score histories offer
|
||||
* the same choice in the same place and look the same doing it.
|
||||
*
|
||||
* The tabs select a CALENDAR-DAY window ending now — `days: 7` is "the last
|
||||
* 7 days", not "the last 7 readings". The persisted preference field keeps
|
||||
* its historical name `rangePoints` (it crosses the chart-overlay-prefs
|
||||
* wire), but its value has always been days. `0` is "All".
|
||||
*/
|
||||
export const CHART_RANGE_TABS = [
|
||||
{ labelKey: "charts.days7Label", days: 7, titleKey: "charts.days7Title" },
|
||||
{ labelKey: "charts.days30Label", days: 30, titleKey: "charts.days30Title" },
|
||||
{ labelKey: "charts.days90Label", days: 90, titleKey: "charts.days90Title" },
|
||||
{ labelKey: "charts.daysAllLabel", days: 0, titleKey: "charts.daysAllTitle" },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
labelKey: string;
|
||||
days: ChartRangePoints;
|
||||
titleKey: string;
|
||||
}>;
|
||||
|
||||
/** The tab a chart opens on before anything was chosen. */
|
||||
export const DEFAULT_CHART_RANGE: ChartRangePoints = 30;
|
||||
|
||||
/**
|
||||
* v1.19.0 — day-span the "All" tab (`0`) fetches. A generous fixed bound
|
||||
* (~10 years) that is clearly larger than any other tab, so "All" means "all
|
||||
* of my history" rather than a silent one-year truncation. Fixed (not per
|
||||
* account) so the fetch-window cache key stays stable across the session.
|
||||
*/
|
||||
export const ALL_RANGE_DAYS = 3650;
|
||||
|
||||
/** The day window a tab asks the server for. */
|
||||
export function rangeWindowDays(range: number): number {
|
||||
return range > 0 ? range : ALL_RANGE_DAYS;
|
||||
}
|
||||
|
||||
export function ChartRangeTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (days: ChartRangePoints) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
{CHART_RANGE_TABS.map((r) => (
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={value === r.days ? "default" : "ghost"}
|
||||
aria-pressed={value === r.days}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => onChange(r.days)}
|
||||
title={t(r.titleKey)}
|
||||
data-slot="chart-range-tab"
|
||||
data-range={r.days}
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
|
||||
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
|
||||
export { BbtChart } from "@/components/cycle/bbt-chart";
|
||||
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
|
||||
export { ScoreTrendChart } from "@/components/insights/derived/score-trend-chart";
|
||||
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
|
||||
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
|
||||
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
|
||||
|
||||
@@ -41,7 +41,6 @@ import { ChartErrorState } from "./chart-error-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useDateFormatPreference,
|
||||
useTranslations,
|
||||
@@ -85,52 +84,15 @@ import {
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { isWholeNumberType } from "@/components/day/use-day-value-format";
|
||||
import {
|
||||
ChartRangeTabs,
|
||||
DEFAULT_CHART_RANGE,
|
||||
rangeWindowDays,
|
||||
} from "./chart-range-tabs";
|
||||
|
||||
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
|
||||
// "the last 7 days", not "the last 7 readings". The labels said "points"
|
||||
// until v1.37.29 while the fetch below always treated the value as a day
|
||||
// window; a user who weighs in twice a week picked "7 pts" and saw two.
|
||||
// The persisted preference field keeps its historical name `rangePoints`
|
||||
// (it crosses the chart-overlay-prefs wire), but its value has always
|
||||
// been days.
|
||||
const TIME_RANGES_KEYS = [
|
||||
{
|
||||
labelKey: "charts.days7Label",
|
||||
days: 7,
|
||||
titleKey: "charts.days7Title",
|
||||
},
|
||||
{
|
||||
labelKey: "charts.days30Label",
|
||||
days: 30,
|
||||
titleKey: "charts.days30Title",
|
||||
},
|
||||
{
|
||||
labelKey: "charts.days90Label",
|
||||
days: 90,
|
||||
titleKey: "charts.days90Title",
|
||||
},
|
||||
{
|
||||
labelKey: "charts.daysAllLabel",
|
||||
days: 0,
|
||||
titleKey: "charts.daysAllTitle",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* v1.19.0 — day-span the "All" range tab (`rangePoints === 0`) fetches.
|
||||
* A generous fixed bound (~10 years) that is clearly larger than any
|
||||
* other range tab, so "All" means "all of my history" rather than the
|
||||
* old silent 365-day truncation. Fixed (not per-account derived) so the
|
||||
* fetch-window cache key stays stable across the session.
|
||||
*
|
||||
* v1.19.2 — the server's daily-aggregate reader now steps UP the bucket
|
||||
* tier (DAY → WEEK → MONTH) for windows wider than the DAY cap, so a
|
||||
* multi-year "All" range returns whole-history coverage downsampled to
|
||||
* the tier the chart's `bucketTimeSeries` would render anyway, instead of
|
||||
* truncating to the most recent ~365 daily buckets. The render cost stays
|
||||
* flat — the coarse tier bounds the point count.
|
||||
*/
|
||||
const ALL_RANGE_DAYS = 3650;
|
||||
// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and
|
||||
// the "All" tab's ten-year fetch span live with the shared
|
||||
// `<ChartRangeTabs>`, so every chart drawn in days offers the same choice.
|
||||
|
||||
interface HealthChartProps {
|
||||
types: string[];
|
||||
@@ -191,6 +153,13 @@ interface HealthChartProps {
|
||||
* its toggles default to OFF (clean line).
|
||||
*/
|
||||
chartKey?: ChartOverlayKey;
|
||||
/**
|
||||
* v1.42 — whether a chart bound to a `chartKey` also shows the overlay
|
||||
* dropdown. Off for a chart that persists only its range tab (the score
|
||||
* histories), so binding a key to remember the range does not add
|
||||
* controls the chart never had. Defaults to on.
|
||||
*/
|
||||
overlayControls?: boolean;
|
||||
/**
|
||||
* v1.4.20 phase B4 — additive storyboard annotations.
|
||||
*
|
||||
@@ -669,6 +638,7 @@ export function HealthChart({
|
||||
windowOverride,
|
||||
compareBaseline = "none",
|
||||
chartKey,
|
||||
overlayControls = true,
|
||||
annotations,
|
||||
verticalMarkers,
|
||||
userTimezone: userTimezoneProp,
|
||||
@@ -715,7 +685,7 @@ export function HealthChart({
|
||||
// hides the range tabs, so the user can't change it.
|
||||
const initialRangePoints = windowOverride
|
||||
? resolveMiniRangePoints(windowOverride)
|
||||
: 30;
|
||||
: DEFAULT_CHART_RANGE;
|
||||
const [rangePoints, setRangePoints] = useState(initialRangePoints);
|
||||
|
||||
// v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow
|
||||
@@ -812,7 +782,7 @@ export function HealthChart({
|
||||
// cache key) and moves on by itself after midnight.
|
||||
const todayKey = localDayKeyFor(new Date(), userTimezone);
|
||||
const fetchWindow = useMemo(() => {
|
||||
const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS;
|
||||
const windowDays = rangeWindowDays(rangePoints);
|
||||
const compareShift =
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? 30
|
||||
@@ -1881,40 +1851,30 @@ export function HealthChart({
|
||||
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
|
||||
data-slot="chart-header-controls"
|
||||
>
|
||||
{TIME_RANGES_KEYS.map((r) => (
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.days ? "default" : "ghost"}
|
||||
aria-pressed={rangePoints === r.days}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => {
|
||||
// v1.12.8 — a range-tab change re-slices `chartData`; the
|
||||
// visible-range stats memo recomputes off the new slice and
|
||||
// the stat strip follows automatically.
|
||||
setRangeHydrated(true);
|
||||
setRangePoints(r.days);
|
||||
// v1.30.1 M2 — persist the pick per chart, same model
|
||||
// as the overlay toggles, so the range survives a
|
||||
// remount instead of resetting to 30 d every visit.
|
||||
if (chartKey && !windowOverride) {
|
||||
overlayPrefs.setPrefs({
|
||||
...overlayPrefs.prefs,
|
||||
rangePoints: r.days,
|
||||
});
|
||||
}
|
||||
}}
|
||||
title={t(r.titleKey)}
|
||||
data-slot="chart-range-tab"
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
<ChartRangeTabs
|
||||
value={rangePoints}
|
||||
onChange={(days) => {
|
||||
// v1.12.8 — a range-tab change re-slices `chartData`; the
|
||||
// visible-range stats memo recomputes off the new slice and
|
||||
// the stat strip follows automatically.
|
||||
setRangeHydrated(true);
|
||||
setRangePoints(days);
|
||||
// v1.30.1 M2 — persist the pick per chart, same model
|
||||
// as the overlay toggles, so the range survives a
|
||||
// remount instead of resetting to 30 d every visit.
|
||||
if (chartKey && !windowOverride) {
|
||||
overlayPrefs.setPrefs({
|
||||
...overlayPrefs.prefs,
|
||||
rangePoints: days,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* v1.4.18 — overlay-controls dropdown anchored next to
|
||||
the range tabs. Only painted when the chart is bound
|
||||
to a persistent chartKey; ad-hoc usages keep the
|
||||
clean-line default. */}
|
||||
{chartKey ? (
|
||||
{chartKey && overlayControls ? (
|
||||
<ChartOverlayControls
|
||||
prefs={overlayPrefs.prefs}
|
||||
onChange={overlayPrefs.setPrefs}
|
||||
|
||||
@@ -366,6 +366,19 @@ describe("<HealthScoreCard> disclosure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> history", () => {
|
||||
it("links the score to its course over time, at rest", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
const rest = atRest(html);
|
||||
const link = rest.match(
|
||||
/<a[^>]*data-slot="health-score-history-link"[^>]*>[\s\S]*?<\/a>/,
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
expect(link![0]).toContain('href="/insights/health-score"');
|
||||
expect(link![0]).toContain("History");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> pillars", () => {
|
||||
it("gives a row to the scored pillars only, never to a gated one", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock("../score-anatomy-view", () => ({
|
||||
// about the assessment's hand-off, so the course is a probe here.
|
||||
vi.mock("../score-history", () => ({
|
||||
ScoreHistoryChart: () => <div data-slot="score-history-probe" />,
|
||||
ScoreHistoryCard: () => <div data-slot="score-history-probe" />,
|
||||
ScoreTrendChartDynamic: () => <div data-slot="score-history-probe" />,
|
||||
}));
|
||||
|
||||
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
|
||||
|
||||
@@ -7,9 +7,10 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
* longer rides above the method.
|
||||
*
|
||||
* The stored nightly scores (recovery, stress, strain) get the full metric
|
||||
* chart over their measurement type; the two composites draw the course their
|
||||
* value carries, and nothing when it carries fewer than two points. The chart
|
||||
* and the card are stubbed to probes that state what they were given.
|
||||
* chart over their measurement type; the two composites chart the daily
|
||||
* history the score-history route serves. Every one of them remembers its
|
||||
* range tab under its own slot. The charts and the card are stubbed to probes
|
||||
* that state what they were given.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
@@ -32,20 +33,30 @@ vi.mock("../score-anatomy-view", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../score-history", () => ({
|
||||
ScoreHistoryChart: ({ type }: { type: string }) => (
|
||||
<div data-slot="history-chart-probe" data-type={type} />
|
||||
),
|
||||
ScoreHistoryCard: ({
|
||||
series,
|
||||
windowDays,
|
||||
ScoreHistoryChart: ({
|
||||
type,
|
||||
chartKey,
|
||||
}: {
|
||||
series: number[];
|
||||
windowDays: number;
|
||||
type: string;
|
||||
chartKey: string;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="history-card-probe"
|
||||
data-points={series.length}
|
||||
data-window={windowDays}
|
||||
data-slot="history-chart-probe"
|
||||
data-type={type}
|
||||
data-chart-key={chartKey}
|
||||
/>
|
||||
),
|
||||
ScoreTrendChartDynamic: ({
|
||||
score,
|
||||
chartKey,
|
||||
}: {
|
||||
score: string;
|
||||
chartKey: string;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="trend-chart-probe"
|
||||
data-score={score}
|
||||
data-chart-key={chartKey}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -85,7 +96,7 @@ describe("score detail history", () => {
|
||||
expect(html).toContain(
|
||||
`data-slot="history-chart-probe" data-type="${metric}"`,
|
||||
);
|
||||
expect(html).not.toContain("history-card-probe");
|
||||
expect(html).not.toContain("trend-chart-probe");
|
||||
// Under the score card, not somewhere else on the page.
|
||||
expect(html.indexOf("history-chart-probe")).toBeGreaterThan(
|
||||
html.indexOf("anatomy-probe"),
|
||||
@@ -93,16 +104,31 @@ describe("score detail history", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["SLEEP_SCORE", "READINESS"] as const)(
|
||||
"%s draws the course its value carries, over its window",
|
||||
(metric) => {
|
||||
it.each([
|
||||
["SLEEP_SCORE", "scoreSleep"],
|
||||
["READINESS", "scoreReadiness"],
|
||||
] as const)(
|
||||
"%s charts its daily history from the score-history route",
|
||||
(metric, chartKey) => {
|
||||
const html = render(metric, [55, 61, 70]);
|
||||
expect(html).toContain('data-points="3"');
|
||||
expect(html).toContain('data-window="30"');
|
||||
expect(html).toContain(
|
||||
`data-slot="trend-chart-probe" data-score="${metric}" data-chart-key="${chartKey}"`,
|
||||
);
|
||||
expect(html).not.toContain("history-chart-probe");
|
||||
expect(html.indexOf("trend-chart-probe")).toBeGreaterThan(
|
||||
html.indexOf("anatomy-probe"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["RECOVERY_SCORE", "scoreRecovery"],
|
||||
["STRESS_SCORE", "scoreStress"],
|
||||
["STRAIN_SCORE", "scoreStrain"],
|
||||
] as const)("%s remembers its range under %s", (metric, chartKey) => {
|
||||
expect(render(metric)).toContain(`data-chart-key="${chartKey}"`);
|
||||
});
|
||||
|
||||
it("an account without a score gets no empty chart under the insufficient card", () => {
|
||||
derived.current = {
|
||||
isLoading: false,
|
||||
@@ -123,9 +149,11 @@ describe("score detail history", () => {
|
||||
expect(html).not.toContain("history-chart-probe");
|
||||
});
|
||||
|
||||
it("a composite with fewer than two points draws no course", () => {
|
||||
expect(render("READINESS", [70])).not.toContain("history-card-probe");
|
||||
expect(render("READINESS")).not.toContain("history-card-probe");
|
||||
it("a composite charts its history even when its value carries no series", () => {
|
||||
// The course no longer rides the value: one point, or none, still gets
|
||||
// the chart, whose own range decides what it shows.
|
||||
expect(render("READINESS", [70])).toContain("trend-chart-probe");
|
||||
expect(render("READINESS")).toContain("trend-chart-probe");
|
||||
});
|
||||
|
||||
it("no score page carries the caveat line above its method", () => {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.42 — the score history chart: the shared range tabs on the remembered
|
||||
* range, the day table as the keyboard way to every point's day, the usual
|
||||
* range and the seam named under the chart, and the honest empty state.
|
||||
* The query is seeded; the plot itself does not draw under SSR.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPut: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
|
||||
}));
|
||||
|
||||
const prefs: {
|
||||
rangePoints: number | undefined;
|
||||
lastKey: string | undefined;
|
||||
setPrefs: ReturnType<typeof vi.fn>;
|
||||
} = { rangePoints: undefined, lastKey: undefined, setPrefs: vi.fn() };
|
||||
vi.mock("@/hooks/use-chart-overlay-prefs", () => ({
|
||||
useChartOverlayPrefs: (key: string) => {
|
||||
prefs.lastKey = key;
|
||||
return {
|
||||
prefs: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
rangePoints: prefs.rangePoints,
|
||||
},
|
||||
setPrefs: prefs.setPrefs,
|
||||
isSaving: false,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
import { ScoreTrendChart, toChartRows } from "../score-trend-chart";
|
||||
|
||||
type Point = { day: string; value: number; seamBreak: boolean };
|
||||
|
||||
function render(
|
||||
days: number,
|
||||
data: {
|
||||
points: Point[];
|
||||
band: { lo: number; hi: number; n: number } | null;
|
||||
} | null,
|
||||
): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
if (data) {
|
||||
client.setQueryData(queryKeys.insightsScoreHistory("HEALTH_SCORE", days), {
|
||||
score: "HEALTH_SCORE",
|
||||
days,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ScoreTrendChart
|
||||
score="HEALTH_SCORE"
|
||||
chartKey="scoreHealth"
|
||||
color="var(--chart-1)"
|
||||
label="Health Score"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const POINTS: Point[] = [
|
||||
{ day: "2026-06-01", value: 61, seamBreak: false },
|
||||
{ day: "2026-06-02", value: 64, seamBreak: false },
|
||||
{ day: "2026-06-03", value: 70, seamBreak: true },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
prefs.rangePoints = undefined;
|
||||
prefs.setPrefs.mockReset();
|
||||
});
|
||||
|
||||
describe("<ScoreTrendChart>", () => {
|
||||
it("offers the four range tabs every chart offers, on 30 days by default", () => {
|
||||
const html = render(30, { points: POINTS, band: null });
|
||||
const tabs = html.match(/<button[^>]*data-slot="chart-range-tab"[^>]*>/g);
|
||||
expect(tabs).toHaveLength(4);
|
||||
expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([
|
||||
"7",
|
||||
"30",
|
||||
"90",
|
||||
"0",
|
||||
]);
|
||||
const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"'));
|
||||
expect(pressed).toHaveLength(1);
|
||||
expect(pressed[0]).toContain('data-range="30"');
|
||||
expect(html).toContain('data-slot="score-trend-chart"');
|
||||
// The range is remembered under the page's own slot.
|
||||
expect(prefs.lastKey).toBe("scoreHealth");
|
||||
});
|
||||
|
||||
it("opens on the remembered range and asks for its window", () => {
|
||||
prefs.rangePoints = 90;
|
||||
const html = render(90, { points: POINTS, band: null });
|
||||
const pressed = (
|
||||
html.match(/<button[^>]*data-slot="chart-range-tab"[^>]*>/g) ?? []
|
||||
).filter((tab) => tab.includes('aria-pressed="true"'));
|
||||
expect(pressed).toHaveLength(1);
|
||||
expect(pressed[0]).toContain('data-range="90"');
|
||||
// The seeded 90-day read is the one the chart drew from.
|
||||
expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("asks for ten years on the All tab", () => {
|
||||
prefs.rangePoints = 0;
|
||||
const html = render(3650, { points: POINTS, band: null });
|
||||
expect(html).toContain('data-slot="chart-data-table"');
|
||||
});
|
||||
|
||||
it("lists every point with a link to its day", () => {
|
||||
const html = render(30, { points: POINTS, band: null });
|
||||
expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3);
|
||||
for (const point of POINTS) {
|
||||
expect(html).toContain(point.day);
|
||||
}
|
||||
expect(html).toContain('data-slot="chart-plot"');
|
||||
});
|
||||
|
||||
it("names the usual range and the seam under the chart", () => {
|
||||
const html = render(30, {
|
||||
points: POINTS,
|
||||
band: { lo: 60, hi: 68, n: 12 },
|
||||
});
|
||||
expect(html).toContain('data-slot="score-trend-band"');
|
||||
expect(html).toContain("your usual range, 60 to 68");
|
||||
expect(html).toContain('data-slot="score-trend-seam"');
|
||||
});
|
||||
|
||||
it("says nothing about a range or a seam it does not have", () => {
|
||||
const html = render(30, {
|
||||
points: POINTS.map((p) => ({ ...p, seamBreak: false })),
|
||||
band: null,
|
||||
});
|
||||
expect(html).not.toContain("score-trend-band");
|
||||
expect(html).not.toContain("score-trend-seam");
|
||||
});
|
||||
|
||||
it("paints the empty state, not an empty plot, for a window without points", () => {
|
||||
const html = render(30, { points: [], band: null });
|
||||
expect(html).toContain('data-slot="chart-empty-state"');
|
||||
expect(html).not.toContain('data-slot="chart-plot"');
|
||||
expect(html).not.toContain('data-slot="chart-data-table"');
|
||||
});
|
||||
|
||||
it("holds the plot's height while the first read is in flight", () => {
|
||||
const html = render(30, null);
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("toChartRows", () => {
|
||||
it("alternates the series key at every seam, so no line crosses one", () => {
|
||||
const rows = toChartRows([
|
||||
{ day: "2026-06-01", value: 50, seamBreak: false },
|
||||
{ day: "2026-06-02", value: 52, seamBreak: false },
|
||||
{ day: "2026-06-03", value: 80, seamBreak: true },
|
||||
{ day: "2026-06-05", value: 81, seamBreak: false },
|
||||
{ day: "2026-06-06", value: 60, seamBreak: true },
|
||||
]);
|
||||
expect(rows.map((r) => [r.even ?? null, r.odd ?? null])).toEqual([
|
||||
[50, null],
|
||||
[52, null],
|
||||
[null, 80],
|
||||
[null, 81],
|
||||
[60, null],
|
||||
]);
|
||||
expect(rows[0]!.timestamp).toBe(Date.parse("2026-06-01T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("does not treat a seam flag on the first point as a break", () => {
|
||||
const [first] = toChartRows([
|
||||
{ day: "2026-06-01", value: 50, seamBreak: true },
|
||||
]);
|
||||
expect(first).toMatchObject({ even: 50 });
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
ScoreAnatomyView,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
import type { RingHue } from "./ring-hues";
|
||||
import { RING_GRADIENT, type RingHue } from "./ring-hues";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
import { ScoreHistoryCard, ScoreHistoryChart } from "./score-history";
|
||||
import { ScoreHistoryChart, ScoreTrendChartDynamic } from "./score-history";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived
|
||||
@@ -98,6 +99,15 @@ const METRIC_COACH_SCOPE: Record<AnatomyMetricId, CoachLaunchScope> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The slot each score page remembers its history chart's range tab under. */
|
||||
const METRIC_CHART_KEY: Record<AnatomyMetricId, ChartOverlayKey> = {
|
||||
SLEEP_SCORE: "scoreSleep",
|
||||
READINESS: "scoreReadiness",
|
||||
RECOVERY_SCORE: "scoreRecovery",
|
||||
STRESS_SCORE: "scoreStress",
|
||||
STRAIN_SCORE: "scoreStrain",
|
||||
};
|
||||
|
||||
const METRIC_HUE: Record<AnatomyMetricId, RingHue> = {
|
||||
SLEEP_SCORE: "sleep",
|
||||
READINESS: "readiness",
|
||||
@@ -284,13 +294,10 @@ export function CompositeScoreAnatomy({
|
||||
// the card always renders its prose rather than the no-provider fallback.
|
||||
const assessment = data.assessment;
|
||||
|
||||
// v1.42 — the score's course, right under its card. A stored nightly score
|
||||
// gets the full chart; a composite draws the course its value carries.
|
||||
const series =
|
||||
data.status === "ok" && data.value
|
||||
? (data.value as { series?: number[] }).series
|
||||
: undefined;
|
||||
// Only beside a score: an account without one gets the honest
|
||||
// v1.42 — the score's course, right under its card, with the range tabs
|
||||
// every chart offers. A stored nightly score charts its readings; readiness
|
||||
// and the sleep score chart the daily history the score-history route
|
||||
// serves. Only beside a score: an account without one gets the honest
|
||||
// insufficient card, not an empty chart under it.
|
||||
const history =
|
||||
data.status !== "ok" ? null : metric === "RECOVERY_SCORE" ||
|
||||
@@ -299,14 +306,16 @@ export function CompositeScoreAnatomy({
|
||||
<ScoreHistoryChart
|
||||
type={deviceStrain ? "DAY_STRAIN" : metric}
|
||||
hue={METRIC_HUE[metric]}
|
||||
chartKey={METRIC_CHART_KEY[metric]}
|
||||
/>
|
||||
) : series && series.length >= 2 ? (
|
||||
<ScoreHistoryCard
|
||||
series={series}
|
||||
windowDays={data.provenance.windowDays}
|
||||
hue={METRIC_HUE[metric]}
|
||||
) : (
|
||||
<ScoreTrendChartDynamic
|
||||
score={metric}
|
||||
chartKey={METRIC_CHART_KEY[metric]}
|
||||
color={RING_GRADIENT[METRIC_HUE[metric]][1]}
|
||||
label={title}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -3,40 +3,46 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { History } from "lucide-react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { RING_GRADIENT, type RingHue } from "./ring-hues";
|
||||
|
||||
/**
|
||||
* v1.42 — a score's course over time on its detail page, directly under the
|
||||
* score card.
|
||||
* v1.42 — a score's course over time on its page, directly under the score
|
||||
* card.
|
||||
*
|
||||
* The three nightly scores (recovery, stress, strain) are stored readings, so
|
||||
* they get the full metric chart every measured page carries: the 7 / 30 / 90
|
||||
* / All switch, the tooltip, and each day opening its day view. The two
|
||||
* composites (sleep score, readiness) are computed rather than stored, so the
|
||||
* course they carry on their value is drawn as a compact line over its window.
|
||||
* they get the full metric chart every measured page carries. The health
|
||||
* score, readiness and the sleep score are not stored as readings; their
|
||||
* daily course comes from the score-history route and draws in
|
||||
* `<ScoreTrendChart>`. Both offer the same range tabs (7 / 30 / 90 / All),
|
||||
* remembered per score page, and open each day from its point.
|
||||
*/
|
||||
|
||||
const DeltaSparkline = dynamic(
|
||||
/** The health score, readiness and sleep score history, loaded with Recharts. */
|
||||
export const ScoreTrendChartDynamic = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.ScoreTrendChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton dayLinks dataTable /> },
|
||||
);
|
||||
|
||||
export function ScoreHistoryChart({
|
||||
type,
|
||||
hue,
|
||||
chartKey,
|
||||
}: {
|
||||
/** The stored measurement type the score is written as — or, for a strain
|
||||
* score served from the device, the device's own `DAY_STRAIN`. */
|
||||
type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE" | "DAY_STRAIN";
|
||||
hue: RingHue;
|
||||
/** The slot the range tab is remembered under. */
|
||||
chartKey: ChartOverlayKey;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -49,44 +55,10 @@ export function ScoreHistoryChart({
|
||||
colors={[RING_GRADIENT[hue][1]]}
|
||||
unit={t("insights.deviceScore.unitScore")}
|
||||
userTimezone={user?.timezone}
|
||||
chartKey={chartKey}
|
||||
overlayControls={false}
|
||||
dayLinks
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreHistoryCard({
|
||||
series,
|
||||
windowDays,
|
||||
hue,
|
||||
}: {
|
||||
series: number[];
|
||||
windowDays: number;
|
||||
hue: RingHue;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Card data-slot="score-history-card" className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={History}
|
||||
title={t("insights.derived.scores.historyTitle")}
|
||||
right={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.derived.scores.historyWindow", { days: windowDays })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-24 w-full" aria-hidden="true">
|
||||
<DeltaSparkline
|
||||
data={series.map((v, i) => ({ i, v }))}
|
||||
strokeVar={RING_GRADIENT[hue][1]}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { History } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartDataTable } from "@/components/charts/chart-data-table";
|
||||
import { ChartEmptyState } from "@/components/charts/chart-empty-state";
|
||||
import { ChartErrorState } from "@/components/charts/chart-error-state";
|
||||
import {
|
||||
ChartRangeTabs,
|
||||
DEFAULT_CHART_RANGE,
|
||||
rangeWindowDays,
|
||||
} from "@/components/charts/chart-range-tabs";
|
||||
import {
|
||||
RichChartTooltip,
|
||||
type RichTooltipRow,
|
||||
} from "@/components/charts/chart-tooltip";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
dayAnchor,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import type { ChartOverlayKey, ChartRangePoints } from "@/lib/dashboard-layout";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { ScoreHistoryId } from "@/lib/insights/score-history-ids";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.42 — a score's daily course on its page, for the scores that have no
|
||||
* stored measurement series to chart: the health score, readiness and the
|
||||
* sleep score (the nightly recovery, stress and strain scores are stored and
|
||||
* ride `<HealthChart>`).
|
||||
*
|
||||
* The same card as the metric charts: the range tabs every chart offers,
|
||||
* remembered per score page like theirs, each point a door to its day (click,
|
||||
* or on touch the tooltip's button), and the data table as the keyboard way
|
||||
* to the same days. The person's usual range is shaded behind the line when
|
||||
* the server could form one.
|
||||
*
|
||||
* A health-score point flagged `seamBreak` starts a new segment: the recipe
|
||||
* changed there, the values either side are averages of different things,
|
||||
* so no line joins them. The segments alternate between two series keys, so
|
||||
* each is drawn by a line that has no value on its neighbour's days.
|
||||
*/
|
||||
|
||||
/** Matches the response schema at `scoreHistoryResponse`. */
|
||||
interface ScoreHistoryResponse {
|
||||
score: ScoreHistoryId;
|
||||
days: number;
|
||||
points: Array<{ day: string; value: number; seamBreak: boolean }>;
|
||||
band: { lo: number; hi: number; n: number } | null;
|
||||
}
|
||||
|
||||
interface ChartRow {
|
||||
day: string;
|
||||
timestamp: number;
|
||||
value: number;
|
||||
/** The value on even segments, absent on odd ones. */
|
||||
even?: number;
|
||||
/** The value on odd segments, absent on even ones. */
|
||||
odd?: number;
|
||||
}
|
||||
|
||||
/** Points past this many draw the line alone; the dots would merge. */
|
||||
const MAX_DOTTED_POINTS = 90;
|
||||
const Y_AXIS_WIDTH = 36;
|
||||
const MARGIN = { top: 10, right: 8, bottom: 8, left: 0 } as const;
|
||||
|
||||
const TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* The points as chart rows: each on its day's noon-UTC anchor, its value on
|
||||
* the series key of its segment. Segments alternate between the two keys, so
|
||||
* the line of one never reaches into its neighbour. Exported for its test.
|
||||
*/
|
||||
export function toChartRows(
|
||||
points: ReadonlyArray<{ day: string; value: number; seamBreak: boolean }>,
|
||||
): ChartRow[] {
|
||||
const rows: ChartRow[] = [];
|
||||
let segment = 0;
|
||||
for (const [index, point] of points.entries()) {
|
||||
if (index > 0 && point.seamBreak) segment += 1;
|
||||
rows.push({
|
||||
day: point.day,
|
||||
timestamp: dayAnchor(point.day),
|
||||
value: point.value,
|
||||
...(segment % 2 === 0 ? { even: point.value } : { odd: point.value }),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface ScoreTrendChartProps {
|
||||
score: ScoreHistoryId;
|
||||
/** The slot the range tab is remembered under. */
|
||||
chartKey: ChartOverlayKey;
|
||||
/** The line's colour, a CSS variable reference. */
|
||||
color: string;
|
||||
/** The score's name: the table caption and the opened day's label. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ScoreTrendChart({
|
||||
score,
|
||||
chartKey,
|
||||
color,
|
||||
label,
|
||||
}: ScoreTrendChartProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const overlayPrefs = useChartOverlayPrefs(chartKey);
|
||||
const [picked, setPicked] = useState<ChartRangePoints | null>(null);
|
||||
const range = picked ?? overlayPrefs.prefs.rangePoints ?? DEFAULT_CHART_RANGE;
|
||||
const days = rangeWindowDays(range);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.insightsScoreHistory(score, days),
|
||||
queryFn: async (): Promise<ScoreHistoryResponse> => {
|
||||
const params = new URLSearchParams({ score, days: String(days) });
|
||||
return apiGet<ScoreHistoryResponse>(
|
||||
`/api/insights/score-history?${params.toString()}`,
|
||||
{ signal: AbortSignal.timeout(TIMEOUT_MS) },
|
||||
);
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
retry: 0,
|
||||
// A range change keeps the previous series painted, dimmed and inert,
|
||||
// until the new one lands, so the card does not jump.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const rows = useMemo<ChartRow[]>(
|
||||
() => toChartRows(query.data?.points ?? []),
|
||||
[query.data],
|
||||
);
|
||||
const hasSeam = (query.data?.points ?? []).some((p) => p.seamBreak);
|
||||
const band = query.data?.band ?? null;
|
||||
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: rows.length > 0,
|
||||
days: rows.map((row) => row.day),
|
||||
focusFor: (index) => {
|
||||
const row = rows[index];
|
||||
return row ? { label, value: String(row.value) } : null;
|
||||
},
|
||||
});
|
||||
const openRow =
|
||||
chartDays.openIndex !== undefined ? rows[chartDays.openIndex] : undefined;
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const dot = rows.length <= MAX_DOTTED_POINTS ? { r: 3, fill: color } : false;
|
||||
const heightClass =
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]";
|
||||
|
||||
function selectRange(next: ChartRangePoints) {
|
||||
setPicked(next);
|
||||
overlayPrefs.setPrefs({ ...overlayPrefs.prefs, rangePoints: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="score-trend-chart"
|
||||
data-score={score}
|
||||
data-range={range}
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<TileHeader
|
||||
icon={History}
|
||||
title={t("insights.derived.scores.historyTitle")}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
|
||||
data-slot="chart-header-controls"
|
||||
>
|
||||
<ChartRangeTabs value={range} onChange={selectRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<Skeleton className={`w-full ${heightClass}`} />
|
||||
) : query.isError ? (
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={label}
|
||||
onAction={() => void query.refetch()}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<ChartEmptyState
|
||||
title={t("charts.noDataInRangeTitle")}
|
||||
description={t("insights.scoreHistory.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
aria-busy={query.isPlaceholderData}
|
||||
className={`relative ${heightClass} transition-opacity ${
|
||||
query.isPlaceholderData ? "pointer-events-none opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={`h-full touch-pan-y ${chartDays.plotClassName}`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={rows}
|
||||
margin={MARGIN}
|
||||
accessibilityLayer
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
// Each point sits at its day's noon-UTC anchor; the
|
||||
// calendar formatter reads it in UTC, so a tick never
|
||||
// names the neighbouring day.
|
||||
tickFormatter={(ts: number) =>
|
||||
fmt.dateShortSmartCalendar(new Date(ts))
|
||||
}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={32}
|
||||
padding={{ left: 10, right: 10 }}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 25, 50, 75, 100]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={Y_AXIS_WIDTH}
|
||||
tickMargin={6}
|
||||
/>
|
||||
{band ? (
|
||||
<ReferenceArea
|
||||
y1={band.lo}
|
||||
y2={band.hi}
|
||||
fill={color}
|
||||
fillOpacity={0.12}
|
||||
strokeOpacity={0}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
) : null}
|
||||
{openRow ? (
|
||||
<ReferenceLine x={openRow.timestamp} {...OPEN_DAY_LINE} />
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={(props) => {
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartRow }> | undefined;
|
||||
const row = payload?.[0]?.payload;
|
||||
if (!props.active || !row) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const tooltipRows: RichTooltipRow[] = [
|
||||
{ name: label, value: String(row.value), color },
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(row.day)}
|
||||
rows={tooltipRows}
|
||||
action={chartDays.tooltipAction(rows.indexOf(row))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{(["even", "odd"] as const).map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={label}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={dot}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={rows}
|
||||
axis="time"
|
||||
// The y axis on the left, plus the x axis padding (10) on both
|
||||
// sides and the plot's right margin.
|
||||
insetLeft={MARGIN.left + Y_AXIS_WIDTH + 10}
|
||||
insetRight={MARGIN.right + 10}
|
||||
/>
|
||||
{band || hasSeam ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{band ? (
|
||||
<p
|
||||
data-slot="score-trend-band"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.scoreHistory.usualRange", {
|
||||
lo: band.lo,
|
||||
hi: band.hi,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{hasSeam ? (
|
||||
<p
|
||||
data-slot="score-trend-seam"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.scoreHistory.seam")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ChartDataTable
|
||||
points={rows.map((row) => ({
|
||||
date: row.day,
|
||||
timestamp: row.timestamp,
|
||||
value: row.value,
|
||||
}))}
|
||||
columns={[{ key: "value", label }]}
|
||||
formatValue={(value) => String(value)}
|
||||
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
|
||||
bucket="day"
|
||||
metricLabel={label}
|
||||
dayLinks
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, History, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
@@ -431,14 +431,25 @@ export function HealthScoreCard({
|
||||
>
|
||||
{t("insights.healthScore.label")}
|
||||
</p>
|
||||
{report.delta != null && report.delta > 0 ? (
|
||||
<span
|
||||
data-slot="health-score-card-delta-chip"
|
||||
className="bg-success/15 text-success shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{report.delta != null && report.delta > 0 ? (
|
||||
<span
|
||||
data-slot="health-score-card-delta-chip"
|
||||
className="bg-success/15 text-success shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
+{report.delta}
|
||||
</span>
|
||||
) : null}
|
||||
{/* v1.42 — the way to the score's course over time. */}
|
||||
<Link
|
||||
href="/insights/health-score"
|
||||
data-slot="health-score-history-link"
|
||||
className="text-foreground/80 hover:text-foreground focus-visible:ring-ring/50 -my-3 inline-flex items-center gap-1 rounded-md py-3 text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
+{report.delta}
|
||||
</span>
|
||||
) : null}
|
||||
<History className="size-3.5" aria-hidden="true" />
|
||||
{t("insights.healthScore.historyLink")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2 — the number. The reason the panel exists. */}
|
||||
|
||||
Reference in new issue
Block a user