mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
866e871102
commit
c41ec02e92
24 files changed
+1048
-385
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12202,6 +12202,10 @@
|
||||
"inTimelineShort": "타임라인",
|
||||
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
|
||||
"chartHintTouch": "점을 탭하면 값이 보여요.",
|
||||
"chartHintBar": "막대를 클릭하면 그날이 열려요.",
|
||||
"chartHintTouchBar": "막대를 탭하면 값이 보여요.",
|
||||
"chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.",
|
||||
"chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.",
|
||||
"coachChips": "이 답변에 쓰인 날",
|
||||
"description": "이 날에 대한 모든 기록이에요.",
|
||||
"openDocument": "문서 열기",
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user