From 8aa83798615792ebe5ccd5afb017531459e40c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 00:52:41 +0100 Subject: [PATCH] fix(gki): draw the index chart in a chart token The custom metric chart drew its line in --primary, which put the index in the action colour right under the ketone series drawn in a chart token. Give the chart an optional series colour, default primary so a custom metric looks as before, and draw the index in --chart-4. --- .../__tests__/delete-audit.test.ts | 5 +- ...custom-metric-chart-color.browser.test.tsx | 49 +++++++++++++++++++ .../custom-metrics/custom-metric-chart.tsx | 35 ++++++++----- src/components/insights/ketones/gki-card.tsx | 1 + 4 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx diff --git a/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts b/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts index f5e0c264e..b1149a259 100644 --- a/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts +++ b/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts @@ -88,7 +88,10 @@ describe("DELETE /api/cycle/feed-subscription", () => { m.findMany.mockResolvedValue([OLD, SUCCESSOR]); await (DELETE as unknown as () => Promise)(); - expect(m.audit.mock.calls.map((c) => c[1].details.feedId)).toEqual([ + const calls = m.audit.mock.calls as unknown as Array< + [string, { details: { feedId: string } }] + >; + expect(calls.map((c) => c[1].details.feedId)).toEqual([ OLD.id, SUCCESSOR.id, ]); diff --git a/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx b/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx new file mode 100644 index 000000000..c112e39db --- /dev/null +++ b/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { renderWithApp } from "../../../../tests/browser/render"; +import { CustomMetricChart } from "../custom-metric-chart"; + +/** + * The line takes the series colour it is given (the GKI draws in a chart + * token beside the ketone series) and keeps primary when it is given none, + * so a custom metric's chart looks as it always has. + */ + +const NOW = Date.now(); +const ENTRIES = [3, 2, 1].map((daysAgo, i) => ({ + id: `e${i}`, + customMetricId: "", + value: 2 + i, + unit: "", + measuredAt: new Date(NOW - daysAgo * 86_400_000).toISOString(), + note: null, +})); + +async function lineStroke(color?: string): Promise { + const { screen } = await renderWithApp( +
+ +
, + ); + const curve = () => screen.container.querySelector(".recharts-line-curve"); + await expect.poll(curve).not.toBeNull(); + return curve()!.getAttribute("stroke"); +} + +describe(" series colour", () => { + it("draws the line in the colour it is given", async () => { + expect(await lineStroke("var(--chart-4)")).toBe("var(--chart-4)"); + }); + + it("keeps primary by default", async () => { + expect(await lineStroke()).toBe("var(--primary)"); + }); +}); diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index ddd7d7498..d3efa1b2e 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -58,6 +58,22 @@ interface ChartPoint { value: number; } +/** The values and any target bound, padded by a tenth of their spread. */ +function yDomainOf( + points: readonly ChartPoint[], + targetLow: number | null, + targetHigh: number | null, +): [number, number] { + const candidates = points.map((p) => p.value); + if (targetLow != null) candidates.push(targetLow); + if (targetHigh != null) candidates.push(targetHigh); + if (candidates.length === 0) return [0, 1]; + const min = Math.min(...candidates); + const max = Math.max(...candidates); + const pad = (max - min || Math.abs(max) || 1) * 0.1; + return [min - pad, max + pad]; +} + export function CustomMetricChart({ entries, name, @@ -65,6 +81,7 @@ export function CustomMetricChart({ targetLow, targetHigh, decimals, + color = "var(--primary)", }: { /** The metric's name: the day's focus label and the data table's caption. */ name: string; @@ -74,6 +91,8 @@ export function CustomMetricChart({ targetLow: number | null; targetHigh: number | null; decimals: number | null; + /** The series colour; a chart token where it sits beside other series. */ + color?: string; }) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -99,16 +118,10 @@ export function CustomMetricChart({ })); }, [entries, range, nowMs]); - const yDomain = useMemo<[number, number]>(() => { - const candidates = points.map((p) => p.value); - if (targetLow != null) candidates.push(targetLow); - if (targetHigh != null) candidates.push(targetHigh); - if (candidates.length === 0) return [0, 1]; - const min = Math.min(...candidates); - const max = Math.max(...candidates); - const pad = (max - min || Math.abs(max) || 1) * 0.1; - return [min - pad, max + pad]; - }, [points, targetLow, targetHigh]); + const yDomain = useMemo( + () => yDomainOf(points, targetLow, targetHigh), + [points, targetLow, targetHigh], + ); // v1.42 — each value opens its day (the calendar day of its instant in // the record's zone) through the doors every day-linked chart has. @@ -134,7 +147,7 @@ export function CustomMetricChart({ chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined; const animate = !prefersReducedMotion(); - const primary = "var(--primary)"; + const primary = color; return (
diff --git a/src/components/insights/ketones/gki-card.tsx b/src/components/insights/ketones/gki-card.tsx index f5e9c2bba..2315116cc 100644 --- a/src/components/insights/ketones/gki-card.tsx +++ b/src/components/insights/ketones/gki-card.tsx @@ -96,6 +96,7 @@ export function GkiCard() { targetLow={null} targetHigh={null} decimals={1} + color="var(--chart-4)" entries={points.map((p) => ({ id: p.id, customMetricId: "",