feat: prepare the next visit from the server's notable window

The block under a planned visit read the day index and up to twelve days
to find what changed, so a dose change on a quiet day could fall out of
the picked days. It now reads GET /api/day/notable for the stretch from
the last visit to today: every change the server names, every notable
reading.

Add a guard that walks every chart, chart mount and calendar heatmap and
fails when one drawn in days lacks the day doors and is not on the
reasoned NOT_HERE list. Give the dose-strength curve, the efficacy chart,
the mood dimensions, the intraday pulse header and the environment note
the same doors.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 15:30:12 +02:00
1 parent 866e871102
commit c41ec02e92
24 files changed
+1048 -385

No files matched your search

+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "Zeitleiste",
"askCoachPrompt": "Was war am {date}?",
"chartHintTouch": "Antippen zeigt den Wert.",
"chartHintBar": "Klick auf einen Balken öffnet den Tag.",
"chartHintTouchBar": "Antippen zeigt die Werte.",
"chartHintCell": "Klick auf einen Tag öffnet ihn.",
"chartHintTouchCell": "Antippen zeigt den Wert des Tages.",
"coachChips": "Tage in dieser Antwort",
"description": "Alles, was deine Einträge für diesen Tag enthalten.",
"openDocument": "Dokument öffnen",
+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "Timeline",
"askCoachPrompt": "What happened on {date}?",
"chartHintTouch": "Tap a point to see its value.",
"chartHintBar": "Click a bar to open its day.",
"chartHintTouchBar": "Tap a bar to see its values.",
"chartHintCell": "Click a day to open it.",
"chartHintTouchCell": "Tap a day to see its value.",
"coachChips": "Days in this answer",
"description": "Everything your records hold for this day.",
"openDocument": "Open document",
+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "Cronología",
"askCoachPrompt": "¿Qué pasó el {date}?",
"chartHintTouch": "Toca un punto para ver su valor.",
"chartHintBar": "Haz clic en una barra para abrir su día.",
"chartHintTouchBar": "Toca una barra para ver sus valores.",
"chartHintCell": "Haz clic en un día para abrirlo.",
"chartHintTouchCell": "Toca un día para ver su valor.",
"coachChips": "Días en esta respuesta",
"description": "Todo lo que tus registros contienen para este día.",
"openDocument": "Abrir documento",
+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "Chronologie",
"askCoachPrompt": "Que s’est-il passé le {date} ?",
"chartHintTouch": "Touchez un point pour voir sa valeur.",
"chartHintBar": "Cliquez sur une barre pour ouvrir son jour.",
"chartHintTouchBar": "Touchez une barre pour voir ses valeurs.",
"chartHintCell": "Cliquez sur un jour pour l’ouvrir.",
"chartHintTouchCell": "Touchez un jour pour voir sa valeur.",
"coachChips": "Jours dans cette réponse",
"description": "Tout ce que vos entrées contiennent pour ce jour.",
"openDocument": "Ouvrir le document",
+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "Cronologia",
"askCoachPrompt": "Cosa è successo il {date}?",
"chartHintTouch": "Tocca un punto per vederne il valore.",
"chartHintBar": "Fai clic su una barra per aprire il suo giorno.",
"chartHintTouchBar": "Tocca una barra per vederne i valori.",
"chartHintCell": "Fai clic su un giorno per aprirlo.",
"chartHintTouchCell": "Tocca un giorno per vederne il valore.",
"coachChips": "Giorni in questa risposta",
"description": "Tutto ciò che le tue voci contengono per questo giorno.",
"openDocument": "Apri documento",
+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "타임라인",
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
"chartHintTouch": "점을 탭하면 값이 보여요.",
"chartHintBar": "막대를 클릭하면 그날이 열려요.",
"chartHintTouchBar": "막대를 탭하면 값이 보여요.",
"chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.",
"chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.",
"coachChips": "이 답변에 쓰인 날",
"description": "이 날에 대한 모든 기록이에요.",
"openDocument": "문서 열기",
+4
View File
@@ -12202,6 +12202,10 @@
"inTimelineShort": "Oś czasu",
"askCoachPrompt": "Co się działo {date}?",
"chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.",
"chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.",
"chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.",
"chartHintCell": "Kliknij dzień, aby go otworzyć.",
"chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.",
"coachChips": "Dni w tej odpowiedzi",
"description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.",
"openDocument": "Otwórz dokument",
@@ -7,11 +7,13 @@ import { ComplianceHeatmap } from "../compliance-heatmap";
/**
* 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells are
* pointer-only SVG `<rect>`s. The keyboard/screen-reader path to the
* per-day values is a visually-hidden day list beside the grid, not
* focusable rects (a cell subtree nested under the SVG's `role="img"` is
* pruned from the a11y tree, and per-cell tab stops would flood the
* keyboard order). This pins that list plus the aggregate `role="img"`
* summary, and that the rects stay a pure pointer affordance.
* per-day values is the data table under the grid (v1.42: the same
* `ChartDataTable` every day-linked chart carries, collapsed until asked
* for), not focusable rects (a cell subtree nested under the SVG's
* `role="img"` is pruned from the a11y tree, and per-cell tab stops would
* flood the keyboard order). This pins that table, each row's date as a link
* to its day, the aggregate `role="img"` summary, and that the rects stay a
* pure pointer affordance.
*/
// The grid window is the last `days` days ending today, so seed relative
// dates (UTC-sliced, matching the component) rather than fixed ones that
@@ -50,18 +52,19 @@ describe("<ComplianceHeatmap> — keyboard-reachable day values", () => {
}
});
it("exposes each active day through a visually-hidden day list", () => {
it("exposes each active day, and its day, through the data table", () => {
const html = render();
// The sr-only list carries one entry per day that had a scheduled or
// taken dose — here the two seeded days.
const listMatch = html.match(
/<ul[^>]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
const table = html.match(
/<div[^>]*data-slot="chart-data-table"[^>]*>([\s\S]*?)<\/table>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/g) ?? [];
expect(items.length).toBe(2);
// Each entry pairs the day with its taken/expected + rate string.
expect(listMatch![1]).toContain("2/2 (100%)");
expect(listMatch![1]).toContain("1/2 (50%)");
expect(table).not.toBeNull();
// One row per day that had a scheduled or taken dose — the two seeded.
const rows = table![1].match(/data-slot="chart-data-table-row"/g) ?? [];
expect(rows.length).toBe(2);
// Taken, expected and the rate, per day.
expect(table![1]).toContain("100 %");
expect(table![1]).toContain("50 %");
expect(table![1]).toContain(`data-day="${D0}"`);
expect(table![1]).toContain(`data-day="${D1}"`);
});
});
@@ -7,11 +7,12 @@ import { MoodHeatmap } from "../mood-heatmap";
/**
* 2026-07-17 a11y audit (M2) — mood heatmap day cells are pointer-only SVG
* `<rect>`s. The keyboard/screen-reader path to the per-day values is a
* visually-hidden day list beside the grid, not focusable rects (a cell
* subtree nested under the SVG's `role="img"` is pruned from the a11y
* tree, and per-cell tab stops would flood the keyboard order). This pins
* that list plus the aggregate `role="img"` summary, and that the rects
* stay a pure pointer affordance.
* the data table under the grid (v1.42: the same `ChartDataTable` every
* day-linked chart carries, collapsed until asked for), not focusable rects
* (a cell subtree nested under the SVG's `role="img"` is pruned from the
* a11y tree, and per-cell tab stops would flood the keyboard order). This
* pins that table, each row's date as a link to its day, the aggregate
* `role="img"` summary, and that the rects stay a pure pointer affordance.
*/
// The grid window is the last `days` days ending today, so seed relative
// dates (UTC-sliced, matching the component) rather than fixed ones that
@@ -50,16 +51,20 @@ describe("<MoodHeatmap> — keyboard-reachable day values", () => {
}
});
it("exposes each logged day through a visually-hidden day list", () => {
it("exposes each logged day, and its day, through the data table", () => {
const html = render();
const listMatch = html.match(
/<ul[^>]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
const table = html.match(
/<div[^>]*data-slot="chart-data-table"[^>]*>([\s\S]*?)<\/table>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/g) ?? [];
// One entry per logged day — the two seeded days, not every cell.
expect(items.length).toBe(2);
expect(listMatch![1]).toContain("4.2");
expect(listMatch![1]).toContain("2.0");
expect(table).not.toBeNull();
const rows = table![1].match(/data-slot="chart-data-table-row"/g) ?? [];
// One row per logged day — the two seeded days, not every cell.
expect(rows.length).toBe(2);
expect(table![1]).toContain("4.2");
expect(table![1]).toContain("2.0");
// Each date opens its day: the keyboard way to the day a click opens.
expect(table![1]).toContain(`data-day="${D0}"`);
expect(table![1]).toContain(`data-day="${D1}"`);
expect(table![1]).toMatch(/data-slot="day-link"/);
});
});
+6 -1
View File
@@ -63,6 +63,11 @@ export interface ChartDataTableColumn {
key: string;
/** Resolved column label, from the chart's own series-label helper. */
label: string;
/**
* The column's own formatter, where its values are not the chart's (a
* count beside a rate). Defaults to the table's `formatValue`.
*/
format?: (value: number) => string;
}
export interface ChartDataTableProps {
@@ -229,7 +234,7 @@ export function ChartDataTable({
</span>
</>
) : (
formatValue(numeric)
(column.format ?? formatValue)(numeric)
)}
</TableCell>
);
+12 -2
View File
@@ -375,6 +375,8 @@ export function ComplianceHeatmap({
points={activeCells.map((cell) => ({
date: cell.dateKey,
timestamp: dayAnchor(cell.dateKey),
taken: cell.data.taken,
expected: cell.data.expected,
rate:
cell.data.expected > 0
? Math.min(
@@ -383,8 +385,16 @@ export function ComplianceHeatmap({
)
: undefined,
}))}
columns={[{ key: "rate", label: t("medications.compliance") }]}
formatValue={(value) => `${value} %`}
columns={[
{ key: "taken", label: t("medications.taken") },
{ key: "expected", label: t("doctorReport.colExpected") },
{
key: "rate",
label: t("medications.compliance"),
format: (value) => `${value} %`,
},
]}
formatValue={(value) => String(value)}
formatDate={(date) => formatDay(dateOnlyKey(date))}
bucket="day"
metricLabel={t("insights.medicationCompliance")}
-1
View File
@@ -1760,7 +1760,6 @@ export function HealthChart({
: [],
focusFor: (index) => dayFocusFor(dayLinkSource?.[index]),
});
const coarsePointer = chartDays.coarse;
const openDayIndex = chartDays.openIndex;
const openDayPoint =
openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// `useUnitDisplay` reads the account through react-query; these static
// renders have no QueryClient, so the hook is a real display for a fixed
@@ -18,9 +19,13 @@ import { unitDisplayFor } from "@/__tests__/helpers/unit-display-mock";
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarDay } from "../types";
// v1.42 — the chart's row of day dots reads the day index through
// react-query, so the static render carries a client (it never fetches).
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
+13 -1
View File
@@ -20,6 +20,11 @@ import { useQuery } from "@tanstack/react-query";
import { CloudSun } from "lucide-react";
import { TagChip } from "@/components/ui/tag-chip";
import {
DAY_LINK_SLOT,
DayLink,
withDayLinkSlot,
} from "@/components/day/day-link";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import {
@@ -91,7 +96,14 @@ export function EnvironmentChip() {
>
<CloudSun className="text-muted-foreground size-4" aria-hidden />
<span className="text-muted-foreground text-xs">
{t("environment.chip.day", { date })}
{/* v1.42 — the date opens the day it names, the one thing every
date in the app does. */}
{withDayLinkSlot(
t("environment.chip.day", { date: DAY_LINK_SLOT }),
<DayLink date={day.date} size="xs">
{date}
</DayLink>,
)}
</span>
{flags.map((flag) => (
<TagChip key={flag.kind} data-flag={flag.kind}>
+75 -14
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { chartPointDayKey, rugPosition } from "../chart-day";
import { chartPointDayKey, rugFraction, rugPosition } from "../chart-day";
import { daysFromCoachSteps } from "../coach-day-chips";
import { pickPreparationDays } from "../since-last-visit";
import { preparationRows } from "../since-last-visit";
import type { CoachStep } from "@/lib/ai/coach/types";
const noon = (key: string) => Date.parse(`${key}T12:00:00.000Z`);
@@ -74,20 +74,81 @@ describe("Coach day chips", () => {
});
describe("visit preparation", () => {
it("reads notable days first, then context days, newest kept, in date order", () => {
const days = pickPreparationDays(
const spell = {
notableTitle: () => "Blood pressure",
notableText: () => "Highest daily value since March",
countMeta: (count: number) => (count > 1 ? `${count} entries` : null),
};
it("lists every change the server names, a dose change on a quiet day included", () => {
const rows = preparationRows(
{
days: {
"2026-08-03": ["medications", "values"],
"2026-08-19": ["illness", "values"],
"2026-09-14": ["labs"],
"2026-09-20": ["values", "mood"],
},
notable: ["2026-09-02"],
changes: [
{
date: "2026-08-03",
kind: "doseChange",
section: "medications",
id: "d1",
title: "Ramipril 5 mg",
count: 1,
href: null,
},
{
date: "2026-09-14",
kind: "labResult",
section: "labs",
id: "l1",
title: "Lab results",
count: 4,
href: null,
},
],
observations: [
{
date: "2026-09-02",
kind: "extremeHigh",
type: "BLOOD_PRESSURE_SYS",
params: { value: 152 },
},
// A first reading says nothing about the stretch since the visit.
{
date: "2026-08-20",
kind: "firstValue",
type: "WEIGHT",
params: {},
},
],
},
3,
spell,
);
expect(rows.map((row) => [row.date, row.title, row.meta])).toEqual([
["2026-08-03", "Ramipril 5 mg", null],
["2026-09-02", "Blood pressure", "Highest daily value since March"],
["2026-09-14", "Lab results", "4 entries"],
]);
});
});
describe("rug placement per axis", () => {
const points = [
{ timestamp: noon("2026-01-01") },
{ timestamp: noon("2026-01-02") },
{ timestamp: noon("2026-01-05") },
];
it("spreads an index axis edge to edge, a band axis at band centres", () => {
expect(rugFraction("2026-01-01", points, "index")).toBe(0);
expect(rugFraction("2026-01-05", points, "index")).toBe(1);
expect(rugFraction("2026-01-01", points, "band")).toBeCloseTo(1 / 6);
expect(rugFraction("2026-01-05", points, "band")).toBeCloseTo(5 / 6);
});
it("places a day on a time axis by its date, not by its neighbours", () => {
// Day 3 of a 4-day span: three quarters along, although it sits between
// the second and third point by index.
expect(rugFraction("2026-01-04", points, "time")).toBeCloseTo(0.75);
expect(rugFraction("2026-01-04", points, "index")).toBeCloseTo(
(1 + 2 / 3) / 2,
);
// A day with only values and mood is not a context change.
expect(days).toEqual(["2026-08-19", "2026-09-02", "2026-09-14"]);
});
});
@@ -0,0 +1,270 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Every chart drawn in days is a door to its days, or it says why not.
*
* The day view arrived on six metric pages and nowhere else, and the recovery
* page beside them offered no day at all: nothing noticed, because every check
* proved one chart. This guard walks `src/app` and `src/components` and holds
* three kinds of surface to the same doors (click, the touch tooltip's button,
* the dashed line through the open day, the row of day dots, the caption):
*
* A. a mount of a chart whose doors are a prop (`HealthChart`, its dynamic
* wrapper, `MoodChart`, the nutrient bar chart) passes `dayLinks`, or is
* a tile's `mini` chart (never a door, by the primitive's contract), or
* forwards its props unchanged (a lazy wrapper);
* B. a file that draws its own Recharts chart over a date axis (an
* `<XAxis>` keyed on a date-shaped field, or `scale="time"`) calls
* `useChartDayLinks` and renders its parts (`ChartDayFooter`,
* `OPEN_DAY_LINE`, the tooltip's props and action);
* C. a calendar heatmap (anything laid out by `heatmapDays`) calls
* `useHeatmapDay`.
*
* A surface that must not open a day stands on `NOT_HERE` with its reason,
* the integration plan's §1.2 list made concrete. An entry that no longer
* names a chart fails, so the list cannot rot into a silent allowlist.
*
* The matcher's limits, stated so nobody mistakes green for complete:
* - a hand-rolled SVG chart that does not use `heatmapDays` (the cycle
* history bars, the lab list's sparkline, the timeline) is invisible;
* - a date carried on an axis under another key (`label`, `x`) slips rule
* B; `DATE_AXIS_KEYS` is the closed set it knows;
* - a new component that wraps `HealthChart` under another name slips rule
* A until it is added to `PROP_GATED`;
* - `dayLinks={someFlag}` counts as on: the guard reads the source, not
* the value at run time.
* The floors at the bottom fail an empty match set, so a matcher that stops
* matching cannot pass by finding nothing.
*/
const ROOT = join(__dirname, "..", "..", "..", "..");
const SCAN = ["src/app", "src/components"];
/** Charts whose doors are switched on per mount. */
const PROP_GATED = [
"HealthChart",
"HealthChartDynamic",
"MoodChart",
"NutrientDailyBarChart",
"NutrientDailyBarChartDynamic",
] as const;
/** `<XAxis dataKey>` values that mean the axis runs over days or instants. */
const DATE_AXIS_KEYS = [
"date",
"day",
"dayKey",
"dayOffset",
"timestamp",
"t",
"pointIndex",
] as const;
/**
* Where a chart deliberately opens no day. Path (a file) → why.
* The integration plan's §1.2 and its table are the source of each reason.
*/
const NOT_HERE: Record<string, string> = {
"src/app/page-client.tsx":
"The dashboard is today (decision D12): its charts are a summary of the record, and past days open as a layer over it from a date, never from the dashboard's own charts.",
"src/components/charts/medication-compliance-chart.tsx":
"Mounted on the dashboard only (see above); the adherence days open from the intake calendar on the medication insights page.",
"src/components/insights/coach-panel/chat-bubble.tsx":
"A Coach answer reaches its days through the chips built from the tool calls (`CoachDayChips`), never from what the answer draws or writes.",
"src/components/insights/coach-panel/result-chart.tsx":
"Same as the Coach answer: the result table's days arrive as tool-derived chips, and a period row may be a month with no single day.",
"src/components/medications/drug-level-chart.tsx":
"A modelled estimate, not a record: every point is computed, none was measured. The doses it is drawn from open their days in the intake history.",
"src/components/charts/scatter-correlation-chart.tsx":
"A pairing of two values per day with no date axis; the pairs carry no date to open.",
"src/components/admin/host-metrics-chart.tsx":
"The server's own resource use, not a health record.",
};
function walk(path: string): string[] {
const abs = join(ROOT, path);
if (statSync(abs).isFile()) return [abs];
return readdirSync(abs).flatMap((name) => {
if (name === "__tests__" || name === "ui") return [];
return walk(join(path, name));
});
}
const files = SCAN.flatMap(walk).filter((f) => /\.tsx?$/.test(f));
const rel = (abs: string) => relative(ROOT, abs);
/**
* The code without its comments: a docblock that names `<HealthChart>` is
* not a mount. Block comments (JSX ones included) go whole; a line comment
* goes from `//` to the line end unless the slashes follow a colon (a URL).
*/
export function stripComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
}
const source = new Map(
files.map((f) => [rel(f), stripComments(readFileSync(f, "utf8"))]),
);
/**
* The text of every JSX opening tag named `name` in `text`: from `<Name` to
* the `>` that closes it at brace depth zero, so an arrow function or a
* comparison inside a prop does not end the tag early.
*/
export function jsxTags(text: string, name: string): string[] {
const out: string[] = [];
const opener = new RegExp(`<${name}(?=[\\s/>])`, "g");
let match: RegExpExecArray | null;
while ((match = opener.exec(text)) !== null) {
let depth = 0;
let quote: string | null = null;
let i = match.index + match[0].length;
for (; i < text.length; i += 1) {
const c = text[i]!;
if (quote) {
if (c === quote && text[i - 1] !== "\\") quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth += 1;
else if (c === "}") depth -= 1;
else if (c === ">" && depth === 0) break;
}
out.push(text.slice(match.index, i + 1));
}
return out;
}
/** True when a prop-gated mount opens its days. */
function mountOpensDays(tag: string): boolean {
return /\bdayLinks\b(?!\s*=\s*\{\s*false\s*\})/.test(tag);
}
function isDateAxis(tag: string): boolean {
if (/scale="time"/.test(tag)) return true;
const key = tag.match(/dataKey="([^"]+)"/)?.[1];
return (
key !== undefined && (DATE_AXIS_KEYS as readonly string[]).includes(key)
);
}
interface Finding {
file: string;
why: string;
}
function findMissingDoors(): {
missing: Finding[];
doors: {
mounts: number;
charts: number;
chartFiles: string[];
heatmaps: number;
};
} {
const missing: Finding[] = [];
const doors = {
mounts: 0,
charts: 0,
chartFiles: [] as string[],
heatmaps: 0,
};
for (const [file, text] of source) {
const listed = file in NOT_HERE;
// A. prop-gated mounts.
for (const name of PROP_GATED) {
for (const tag of jsxTags(text, name)) {
if (/\{\s*\.\.\.props\s*\}/.test(tag)) continue; // a lazy wrapper
if (/\bmini\b/.test(tag)) continue; // a tile's sparkline
if (mountOpensDays(tag)) {
doors.mounts += 1;
continue;
}
if (!listed) missing.push({ file, why: `<${name}> without dayLinks` });
}
}
// B. a Recharts chart over a date axis, drawn in this file.
if (/from\s+["']recharts["']/.test(text)) {
const dated = jsxTags(text, "XAxis").some(isDateAxis);
if (dated) {
const wired =
/\buseChartDayLinks\(/.test(text) &&
/\bChartDayFooter\b/.test(text) &&
/\bOPEN_DAY_LINE\b/.test(text) &&
/\.tooltipProps\b/.test(text) &&
/\.tooltipAction\(/.test(text);
if (wired) {
doors.charts += 1;
doors.chartFiles.push(file);
} else if (!listed) {
missing.push({
file,
why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props and action)",
});
}
}
}
// C. a calendar heatmap.
if (/\bheatmapDays\(/.test(text) && !file.startsWith("src/lib/")) {
if (/\buseHeatmapDay\(/.test(text)) doors.heatmaps += 1;
else if (!listed) {
missing.push({ file, why: "a calendar heatmap without useHeatmapDay" });
}
}
}
return { missing, doors };
}
describe("every chart drawn in days opens its days, or says why not", () => {
const { missing, doors } = findMissingDoors();
it("finds no date-axis chart, heatmap or chart mount without the day doors", () => {
expect(missing).toEqual([]);
});
it("still finds the doors it is meant to find (an empty match set fails)", () => {
// HealthChart on the metric pages (two mounts in the HealthKit page),
// blood pressure, weight, pulse, BMI, recovery, sleep, the mood line,
// water and caffeine.
expect(doors.mounts).toBeGreaterThanOrEqual(11);
// HealthChart, the mood line and the nutrient bars themselves, sleep
// stages, mood dimensions, lab marker, custom metric, questionnaire,
// basal temperature, dose strength, efficacy: eleven.
expect(
doors.chartFiles.length,
doors.chartFiles.join("\n"),
).toBeGreaterThanOrEqual(11);
// The mood and the intake calendars.
expect(doors.heatmaps).toBeGreaterThanOrEqual(2);
});
it("every NOT_HERE entry names an existing chart and gives a reason", () => {
for (const [file, reason] of Object.entries(NOT_HERE)) {
const text = source.get(file);
expect(text, `${file} is listed but does not exist`).toBeDefined();
const charts =
/from\s+["']recharts["']/.test(text!) ||
PROP_GATED.some((name) => jsxTags(text!, name).length > 0);
expect(charts, `${file} is listed but draws no chart`).toBe(true);
expect(reason.length).toBeGreaterThan(30);
}
});
it("reads a JSX tag to its real end, past arrow functions in its props", () => {
const [tag] = jsxTags(
`<HealthChart onVisibleStats={(s) => s > 1} dayLinks />`,
"HealthChart",
);
expect(tag).toContain("dayLinks");
expect(mountOpensDays("<HealthChart dayLinks={false} />")).toBe(false);
expect(isDateAxis('<XAxis dataKey="t" type="number" />')).toBe(true);
expect(isDateAxis('<XAxis dataKey="label" />')).toBe(false);
});
});
+1 -14
View File
@@ -8,7 +8,7 @@ import { dateOnlyKey } from "@/lib/tz/date-only";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller";
import { useOpenDay } from "./day-layer-controller";
import { isOpenableDay } from "./day-url";
import { useDayIndex } from "./use-day";
import { useTodayKey } from "./use-today-key";
@@ -59,19 +59,6 @@ export function chartPointDayKey(timestamp: number): DateKey {
return dateOnlyKey(new Date(timestamp));
}
/** Open the day of a chart point, carrying the value as the day's focus. */
export function openChartDay(
timestamp: number,
focus: Omit<DayFocus, "date"> | null,
trigger?: HTMLElement | null,
): void {
const date = chartPointDayKey(timestamp);
openDay(date, {
focus: focus ? { ...focus, date } : null,
trigger: trigger ?? null,
});
}
/** The tooltip's way to the day on a touch screen. */
export function TooltipDayAction({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslations();
+61 -99
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useState } from "react";
import { useQueries } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CircleDashed,
@@ -24,8 +24,7 @@ import type {
DateKey,
DayEventKind,
DayNotable,
DayResponse,
DaySectionKey,
DayNotableWindowResponse,
} from "@/lib/day/contract";
import {
useDisplayTimezone,
@@ -37,9 +36,8 @@ import { queryKeys } from "@/lib/query-keys";
import { DAY_SECTION_LABEL, useNotableText } from "./day-sections";
import { DayLink } from "./day-link";
import { dateKeyOfInstant, shiftDateKey, daysBetween } from "./day-url";
import { dateKeyOfInstant } from "./day-url";
import { tileKeyOf } from "./day-values-model";
import { useDayIndex } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
import { useTodayKey } from "./use-today-key";
@@ -50,35 +48,12 @@ import { useTodayKey } from "./use-today-key";
* opening its day. Compiled from the record without any judgement; the
* wording is the day view's own.
*
* Built from what the day routes already answer: the index names the days
* that carry a context change or a notable observation, and those days (at
* most {@link MAX_DAYS_READ}) are read for their entries. Nothing is
* recomputed here.
* One read, `GET /api/day/notable` for the window from the last visit to
* today: the server lists every context change in it (a dose change always,
* however quiet the day around it) and every notable observation. Nothing is
* recomputed here; this only spells the rows.
*/
/** Sections whose presence alone makes a day worth reading here. */
const CONTEXT_SECTIONS: readonly DaySectionKey[] = [
"illness",
"labs",
"vaccinations",
"visits",
"allergies",
];
/** Entry kinds that are a change of context, the rows this block lists. */
const CONTEXT_EVENTS: ReadonlySet<DayEventKind> = new Set<DayEventKind>([
"doseChange",
"medicationStart",
"medicationEnd",
"pauseStart",
"courseStart",
"illnessOnset",
"allergyOnset",
"labResult",
"procedure",
"vaccination",
]);
const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
doseChange: Pill,
medicationStart: Pill,
@@ -92,12 +67,10 @@ const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
vaccination: Syringe,
};
/** Days read for their entries, newest kept when there are more. */
export const MAX_DAYS_READ = 12;
/** Rows shown before "All". */
export const VISIBLE_ROWS = 5;
interface PrepRow {
export interface PrepRow {
key: string;
date: DateKey;
Icon: LucideIcon;
@@ -105,28 +78,41 @@ interface PrepRow {
meta: string | null;
}
/** The days to read: notable ones first, then context days, newest first. */
export function pickPreparationDays(
index: {
days: Record<string, readonly string[]>;
notable: readonly string[];
/**
* The window's rows, oldest first: each context change as the server named
* it, each notable observation spelled by the caller. A first reading of a
* kind is left out here (it says nothing about the stretch since the visit).
*/
export function preparationRows(
window: Pick<DayNotableWindowResponse, "observations" | "changes">,
spell: {
notableTitle: (notable: DayNotable) => string;
notableText: (notable: DayNotable) => string | null;
countMeta: (count: number) => string | null;
},
max = MAX_DAYS_READ,
): DateKey[] {
const notable = [...new Set(index.notable)].sort().reverse();
const context = Object.entries(index.days)
.filter(([, sections]) =>
sections.some((s) => CONTEXT_SECTIONS.includes(s as DaySectionKey)),
)
.map(([day]) => day)
.sort()
.reverse();
const picked: DateKey[] = [];
for (const day of [...notable, ...context]) {
if (!picked.includes(day)) picked.push(day);
if (picked.length >= max) break;
): PrepRow[] {
const rows: PrepRow[] = [];
for (const change of window.changes) {
rows.push({
key: `${change.kind}-${change.id}-${change.date}`,
date: change.date,
Icon: EVENT_ICON[change.kind] ?? FileText,
title: change.title,
meta: spell.countMeta(change.count),
});
}
return picked.sort();
for (const notable of window.observations) {
if (notable.kind === "firstValue") continue;
rows.push({
key: `notable-${notable.date}-${notable.kind}-${notable.type ?? ""}`,
date: notable.date,
Icon: notable.kind === "gap" ? CircleDashed : Activity,
title: spell.notableTitle(notable),
meta: spell.notableText(notable),
});
}
// A stable sort: the server's order holds within a day.
return rows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}
export function SinceLastVisit({
@@ -147,20 +133,16 @@ export function SinceLastVisit({
const [showAll, setShowAll] = useState(false);
const lastDay = dateKeyOfInstant(last.occurredAt, timeZone);
// The window starts the day after the last visit and ends today; the index
// answers at most a year at a time, which is the stretch that matters here.
const from =
daysBetween(shiftDateKey(lastDay, 1), today) > 365
? shiftDateKey(today, -365)
: shiftDateKey(lastDay, 1);
const index = useDayIndex(from, today, from <= today);
const days = index.data ? pickPreparationDays(index.data) : [];
const reads = useQueries({
queries: days.map((day) => ({
queryKey: queryKeys.day(day),
queryFn: () => apiGet<DayResponse>(`/api/day/${day}`),
staleTime: 5 * 60_000,
})),
// From the last visit's own day (a dose changed at the visit belongs to
// what the next one should know) to today. The server caps the span.
const window = useQuery({
queryKey: queryKeys.dayNotable(lastDay, today),
queryFn: () => {
const params = new URLSearchParams({ from: lastDay, to: today });
return apiGet<DayNotableWindowResponse>(`/api/day/notable?${params}`);
},
enabled: lastDay <= today,
staleTime: 5 * 60_000,
});
// "Blood pressure 152/94 mmHg": the reading a notable day is about, in the
@@ -186,37 +168,17 @@ export function SinceLastVisit({
return `${label} ${shown.value}${shown.unit ? ` ${shown.unit}` : ""}`;
};
const rows: PrepRow[] = [];
reads.forEach((read, i) => {
const day = days[i];
if (!day || !read.data) return;
for (const event of read.data.events) {
if (!CONTEXT_EVENTS.has(event.kind)) continue;
rows.push({
key: `${event.kind}-${event.id}`,
date: day,
Icon: EVENT_ICON[event.kind] ?? FileText,
title: event.title,
meta: event.meta,
});
}
for (const notable of read.data.notable) {
if (notable.kind === "firstValue") continue;
rows.push({
key: `notable-${day}-${notable.kind}-${notable.type ?? ""}`,
date: day,
Icon: notable.kind === "gap" ? CircleDashed : Activity,
title: notableTitle(notable),
meta: notableText(notable),
});
}
});
rows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
const rows = window.data
? preparationRows(window.data, {
notableTitle,
notableText,
countMeta: (count) =>
count > 1 ? tCount("day.countEntries", count) : null,
})
: [];
const loading =
index.isPending ||
reads.some((read) => read.isPending && read.fetchStatus !== "idle");
const failed = index.isError || reads.some((read) => read.isError);
const loading = window.isPending && window.fetchStatus !== "idle";
const failed = window.isError;
if (!loading && !failed && rows.length === 0) return null;
// A preparation that could not be read is left out rather than shown half:
// the visit card stands on its own, and the day links stay everywhere else.
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
@@ -31,9 +32,13 @@ import {
* path stays render-safe)
*/
// v1.42 — the chart's row of day dots reads the day index through
// react-query, so the static render carries a client (it never fetches).
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -21,6 +21,7 @@ import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import { DayLink } from "@/components/day/day-link";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -219,8 +220,12 @@ export function IntradayPulseChart({
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Button>
<span className="text-muted-foreground min-w-14 text-center text-xs tabular-nums">
{dayLabel}
{/* v1.42 — the date opens the whole day this chart is one part
of, the one thing every date in the app does. */}
<span className="min-w-14 text-center text-xs">
<DayLink date={dateKey} size="xs">
{dayLabel}
</DayLink>
</span>
<Button
type="button"
@@ -5,13 +5,25 @@ import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
MOOD_DIMENSIONS,
MOOD_DIMENSION_WINDOWS,
@@ -78,6 +90,7 @@ export function MoodDimensionTrends({
dimensions: MoodDimensionSummaryData[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [window, setWindow] = useState<MoodDimensionWindow>(30);
const present = dimensions.filter((d) => d.present);
@@ -105,6 +118,14 @@ export function MoodDimensionTrends({
return dimension ? t(dimension.labelKey) : key;
};
// v1.42 — each answered day opens its day, through the doors every
// day-linked chart has.
const rowDays = chartData.map((row) => String(row.date));
const chartDays = useChartDayLinks({
enabled: present.length > 0 && chartData.length > 0,
days: rowDays,
});
if (present.length === 0) {
return (
<p className="text-muted-foreground text-sm">
@@ -134,17 +155,27 @@ export function MoodDimensionTrends({
))}
</div>
<div className="h-[clamp(160px,34vh,220px)] w-full">
<div
data-slot="chart-plot"
{...chartDays.plotProps}
className={cn(
"h-[clamp(160px,34vh,220px)] w-full",
chartDays.plotClassName,
)}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickFormatter={(value: string) => value.slice(5)}
tickFormatter={(value: string) =>
fmt.dateShortSmartCalendar(value)
}
minTickGap={24}
/>
<YAxis
@@ -152,18 +183,43 @@ export function MoodDimensionTrends({
ticks={[0, 5, 10]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
/>
{chartDays.openIndex !== undefined ? (
<ReferenceLine
x={rowDays[chartDays.openIndex]}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
contentStyle={{
background: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
{...chartDays.tooltipProps}
content={(props) => {
const payload = props.payload as unknown as
| ReadonlyArray<{
dataKey?: string | number;
value?: number;
color?: string;
payload?: Record<string, number | string>;
}>
| undefined;
const row = payload?.[0]?.payload;
if (!props.active || !row) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = (payload ?? [])
.filter((item) => typeof item.value === "number")
.map((item) => ({
name: labelFor(String(item.dataKey)),
value: String(item.value),
color: item.color ?? "var(--chart-1)",
}));
return (
<RichChartTooltip
active
label={fmt.dateShortSmartCalendar(String(row.date))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(row))}
/>
);
}}
labelStyle={{ color: "var(--muted-foreground)" }}
formatter={(value, name) => [
String(value ?? ""),
labelFor(String(name)),
]}
/>
{present.map((summary) => (
<Line
@@ -183,6 +239,32 @@ export function MoodDimensionTrends({
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={rowDays.map((day) => ({ timestamp: dayAnchor(day) }))}
// The y axis (60) less the plot's negative left margin (20); the
// margin (8) on the right.
insetLeft={60 - 20}
insetRight={8}
/>
{chartDays.active ? (
<ChartDataTable
points={chartData.map((row) => ({
...row,
date: String(row.date),
timestamp: dayAnchor(String(row.date)),
}))}
columns={present.map((summary) => ({
key: summary.key,
label: labelFor(summary.key),
}))}
formatValue={(value) => String(value)}
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
bucket="day"
metricLabel={t("insights.mood.dimensions.title")}
dayLinks
/>
) : null}
<ul className="space-y-1.5">
{present.map((summary) => {
@@ -23,10 +23,22 @@ import {
Tooltip,
ReferenceLine,
ReferenceArea,
ReferenceDot,
} from "recharts";
import { makeFormatters } from "@/lib/format-locale";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { dateKeyOfInstant } from "@/components/day/day-url";
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
export interface EfficacyChartTarget {
label: string;
@@ -137,6 +149,40 @@ export function EfficacyChart({
return [Math.floor(min - pad), Math.ceil(max + pad)];
}, [displaySeries]);
// v1.42 — each value of the target series opens its day, through the doors
// every day-linked chart has. The series and the adherence lane are two
// `<Line data>` arrays on one time axis, so a click is placed by its x
// value, not by an index into the chart's data.
const seriesDays = displaySeries.map((p) => {
const instant = new Date(p.t);
return isNoonUtcAnchor(instant)
? dateOnlyKey(instant)
: dateKeyOfInstant(instant, timezone);
});
const chartDays = useChartDayLinks({
enabled: displaySeries.length > 0,
days: seriesDays,
focusFor: (index) => {
const point = displaySeries[index];
if (!point) return null;
return {
label: target.label,
value: mode === "percent" ? `${point.value}%` : String(point.value),
unit: mode === "percent" ? undefined : (target.unit ?? undefined),
};
},
indexOfClick: (state) => {
const x = Number(state.activeLabel);
if (!Number.isFinite(x)) return null;
const at = displaySeries.findIndex((p) => p.t === x);
return at === -1 ? null : at;
},
});
const openPoint =
chartDays.openIndex !== undefined
? displaySeries[chartDays.openIndex]
: undefined;
if (seriesData.length === 0 || !domain) return null;
const unitSuffix = mode === "percent" ? "%" : target.unit;
@@ -145,153 +191,221 @@ export function EfficacyChart({
// domain max keeps the render pure (no `Date.now()` in the render body).
const rightEdge = domain[1];
return (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={220}>
<LineChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
domain={targetDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={axisUnitSuffix(unitSuffix)}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
<div>
<div
className={`touch-pan-y ${chartDays.plotClassName}`}
data-slot="chart-plot"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height={220}>
<LineChart
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
domain={targetDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={axisUnitSuffix(unitSuffix)}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
{/* Population reference band — faint context, labelled "typical
{/* Population reference band — faint context, labelled "typical
range", explicitly NOT a target line. Hidden in percent mode:
an absolute band is meaningless once the series is normalized. */}
{mode === "absolute" && target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{mode === "absolute" && target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={displaySeries}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={displaySeries}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
{/* The open day: the dashed line every chart draws through it,
and a ring on its value, which keeps it apart from the quiet
dose-change lines. */}
{openPoint ? (
<ReferenceLine
yAxisId="target"
x={openPoint.t}
{...OPEN_DAY_LINE}
/>
) : null}
{openPoint ? (
<ReferenceDot
yAxisId="target"
x={openPoint.t}
y={openPoint.value}
r={6.5}
fill="var(--card)"
stroke="var(--chart-1)"
strokeWidth={2.5}
ifOverflow="discard"
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
content={(props) => {
const payload = props.payload as unknown as
| ReadonlyArray<{
name?: string;
value?: number;
color?: string;
payload?: { t: number };
}>
| undefined;
if (!props.active || !payload || payload.length === 0) {
return <RichChartTooltip active={false} rows={[]} />;
}
const x = payload[0]?.payload?.t;
const rows: RichTooltipRow[] = payload
.filter((item) => typeof item.value === "number")
.map((item) => {
const num = Number(item.value);
const value =
item.name === adherenceLabel
? `${Math.round(num)}%`
: mode === "percent"
? `${num}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return {
name: String(item.name ?? ""),
value,
color: item.color ?? "var(--chart-1)",
};
});
const index =
typeof x === "number"
? displaySeries.findIndex((p) => p.t === x)
: -1;
return (
<RichChartTooltip
active
label={typeof x === "number" ? fmt.date(new Date(x)) : ""}
rows={rows}
action={chartDays.tooltipAction(
index === -1 ? undefined : index,
)}
/>
);
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.8125rem",
}}
labelFormatter={(v) => fmt.date(new Date(Number(v)))}
formatter={(value, name) => {
const num = Number(value);
if (name === adherenceLabel) {
return [`${Math.round(num)}%`, String(name)];
}
const text =
mode === "percent"
? `${num}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return [text, String(name)];
}}
/>
</LineChart>
</ResponsiveContainer>
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
// The axis spans the series and the adherence lane together.
points={[{ timestamp: domain[0] }, { timestamp: domain[1] }]}
axis="time"
// The y axis (40) on the left, the margin (8) on the right.
insetLeft={40}
insetRight={8}
/>
</div>
);
}
@@ -39,13 +39,31 @@ import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useTranslations,
useFormatters,
} from "@/lib/i18n/context";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { dateKeyOfInstant } from "@/components/day/day-url";
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -175,6 +193,44 @@ export function DoseStrengthCurve({
const unit = latest?.doseUnit ?? "mg";
// v1.42 — each dose change opens the day it took effect, through the doors
// every day-linked chart has. The point carried forward to today is not a
// change and opens nothing.
const timeZone = useDisplayTimezone();
const changeCount = Math.min(
doseChanges.filter(
(dc) =>
Number.isFinite(Date.parse(dc.effectiveFrom)) &&
Number.isFinite(dc.doseValue),
).length,
chartData.length,
);
const pointDays = chartData.map((p, index) => {
if (index >= changeCount) return null;
const instant = new Date(p.t);
return isNoonUtcAnchor(instant)
? dateOnlyKey(instant)
: dateKeyOfInstant(instant, timeZone);
});
const chartDays = useChartDayLinks({
enabled: hasCurve,
days: pointDays,
focusFor: (index) => {
const point = chartData[index];
return point
? {
label: t("medications.doseStrength.tooltipLabel"),
value: fmt.number(point.dose),
unit,
}
: null;
},
});
const openPoint =
chartDays.openIndex !== undefined
? chartData[chartDays.openIndex]
: undefined;
const body = isLoading ? (
<div
className="flex h-[200px] min-h-[200px] items-center justify-center"
@@ -203,72 +259,123 @@ export function DoseStrengthCurve({
<p className="text-xs italic">{t("medications.doseStrength.empty")}</p>
</div>
) : (
<div
className="touch-pan-y"
style={{ height: "200px" }}
data-slot="dose-strength-curve-area"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={[0, "auto"]}
width={34}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => fmt.number(v as number)}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
labelFormatter={(v) => fmt.dateShortSmart(new Date(v as number))}
formatter={(value) => [
`${fmt.number(value as number)} ${unit}`,
t("medications.doseStrength.tooltipLabel"),
]}
contentStyle={{
backgroundColor: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
fontSize: "0.75rem",
}}
/>
<Line
type="stepAfter"
dataKey="dose"
stroke={CURVE_COLOR}
strokeWidth={2}
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</LineChart>
</ResponsiveContainer>
</div>
<>
<div
className={`touch-pan-y ${chartDays.plotClassName}`}
style={{ height: "200px" }}
data-slot="dose-strength-curve-area"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={[0, "auto"]}
width={34}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => fmt.number(v as number)}
/>
{openPoint ? (
<ReferenceLine x={openPoint.t} {...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 unknown as
| ReadonlyArray<{ payload?: { t: number; dose: number } }>
| undefined;
const point = payload?.[0]?.payload;
if (!props.active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("medications.doseStrength.tooltipLabel"),
value: `${fmt.number(point.dose)} ${unit}`,
color: CURVE_COLOR,
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmart(new Date(point.t))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(point))}
/>
);
}}
/>
<Line
type="stepAfter"
dataKey="dose"
stroke={CURVE_COLOR}
strokeWidth={2}
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={chartData.map((p) => ({ timestamp: p.t }))}
axis="time"
// The margin (4) + the y axis (34) on the left, the margin (14) on
// the right.
insetLeft={4 + 34}
insetRight={14}
/>
{chartDays.active ? (
<ChartDataTable
points={chartData.slice(0, changeCount).map((p, index) => ({
date: pointDays[index]!,
timestamp: dayAnchor(pointDays[index]!),
dose: p.dose,
}))}
columns={[
{
key: "dose",
label: t("medications.doseStrength.tooltipLabel"),
},
]}
unit={unit}
formatValue={(value) => fmt.number(value)}
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
bucket="day"
metricLabel={t("medications.doseStrength.title")}
dayLinks
/>
) : null}
</>
);
return (
+3
View File
@@ -15,4 +15,7 @@ export const dayKeys = {
day: (date: string) => ["day", "detail", date] as const,
/** `GET /api/day/index`: the window bounds are the key. */
dayIndex: (from: string, to: string) => ["day", "index", from, to] as const,
/** `GET /api/day/notable`: what changed in a window, for a visit. */
dayNotable: (from: string, to: string) =>
["day", "notable", from, to] as const,
};