mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge consistent day access into release/v1.42.0
This commit is contained in:
commit
06bd75b36e
65 files changed
+3967
-1668
No files matched your search
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 429,
|
||||
"/insights/page": 304,
|
||||
@@ -8,6 +8,6 @@
|
||||
"/settings/[section]/page": 403
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 4400,
|
||||
"totalClientKbGz": 4470,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture";
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import {
|
||||
DAY_DOORS_STORAGE_STATE_PATH,
|
||||
MOBILE_ROUTES_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
/**
|
||||
* v1.42 — every chart drawn in days opens its days, the same way.
|
||||
*
|
||||
* The day view first reached six metric pages; the recovery page beside them
|
||||
* offered no day at all. These journeys walk the surfaces that came after,
|
||||
* each through the same doors: on a fine pointer a click on a point (or a
|
||||
* calendar cell) opens the day beside the page; on a touch screen the first
|
||||
* tap shows the value and the tooltip's "View the whole day" opens it as a
|
||||
* bottom sheet, the tapped value on top. A date in a list opens in one tap.
|
||||
* The dashed line (or outline) marks the open day, the row of dots and the
|
||||
* caption sit under every chart, and the data table's dates are the
|
||||
* keyboard way to the same days.
|
||||
*
|
||||
* - recovery (the page the beta reported), sleep, a lab marker, a chart
|
||||
* with its comparison period on and a dashboard chart: line charts;
|
||||
* - the mood calendar: a heatmap;
|
||||
* - a metric's "all values" list: a date in a row.
|
||||
*
|
||||
* The day itself is a route mock in the contract's shape, as in
|
||||
* `day-view.spec.ts`: these journeys pin the client's doors, not the day.
|
||||
* Stable data attributes only.
|
||||
*/
|
||||
|
||||
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
const isPhone = (testInfo: TestInfo) =>
|
||||
testInfo.project.name === "chromium-mobile";
|
||||
|
||||
/** Open the day of the point at 80 % across a day-linked plot. */
|
||||
async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
|
||||
await plot.scrollIntoViewIfNeeded();
|
||||
await expect(plot).toHaveAttribute("data-day-links", "true");
|
||||
const box = (await plot.boundingBox())!;
|
||||
const x = box.x + box.width * 0.8;
|
||||
const y = box.y + box.height / 2;
|
||||
if (isPhone(testInfo)) {
|
||||
await page.touchscreen.tap(x, y);
|
||||
const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible');
|
||||
await expect(open).toBeVisible();
|
||||
// A tap alone never opens a sheet: a point is a small target.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
// The button is a calm, full 44 px row.
|
||||
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
} else {
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.click(x, y);
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
||||
}
|
||||
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
|
||||
await expect(page.locator('[data-slot="day-view"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("every chart drawn in days opens its days", () => {
|
||||
test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page, context }, testInfo) => {
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
await mockDay(page, { index: "full" });
|
||||
});
|
||||
|
||||
test("recovery: a point opens its day, and the open day is marked", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
if (!isPhone(testInfo)) {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
}
|
||||
await page.goto("/insights/recovery");
|
||||
const plot = page
|
||||
.locator('[data-slot="chart-plot"][data-day-links="true"]')
|
||||
.first();
|
||||
await expect(plot).toBeVisible();
|
||||
// Under the chart: the caption and the row of day dots, which draws the
|
||||
// days the index names, one of them ringed as notable.
|
||||
const rug = page.locator('[data-slot="day-rug"]').first();
|
||||
await expect(
|
||||
page.locator('[data-slot="chart-day-caption"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(rug.locator("[data-day]").first()).toBeAttached();
|
||||
await expect(rug.locator('[data-notable="true"]')).toHaveCount(1);
|
||||
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
const day = await page
|
||||
.locator('[data-slot="day-view"]')
|
||||
.getAttribute("data-day");
|
||||
// The value the person came from heads the day.
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
// The open day's dot is the filled one.
|
||||
await expect(rug.locator(`[data-day="${day}"]`)).toHaveAttribute(
|
||||
"data-open",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("recovery: the data table's dates are the keyboard way to a day", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(isPhone(testInfo), "keyboard path");
|
||||
await page.goto("/insights/recovery");
|
||||
const toggle = page
|
||||
.locator('[data-slot="chart-data-table-toggle"]')
|
||||
.first();
|
||||
await toggle.scrollIntoViewIfNeeded();
|
||||
await toggle.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const link = page
|
||||
.locator('[data-slot="chart-data-table-region"] [data-slot="day-link"]')
|
||||
.first();
|
||||
await link.focus();
|
||||
const day = await link.getAttribute("data-day");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day!,
|
||||
);
|
||||
// Escape closes the day and gives the keyboard back to the date.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sleep: a night's point opens its day", async ({ page }, testInfo) => {
|
||||
await page.goto("/insights/sleep");
|
||||
const plot = page
|
||||
.locator('[data-slot="chart-plot"][data-day-links="true"]')
|
||||
.first();
|
||||
await expect(plot).toBeVisible();
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
});
|
||||
|
||||
test("the mood calendar: a logged day's cell opens it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto("/insights/mood");
|
||||
// A logged, openable cell: it carries the pointer cursor.
|
||||
const cell = page.locator("rect.cursor-pointer[data-day]").last();
|
||||
await cell.scrollIntoViewIfNeeded();
|
||||
await expect(cell).toBeVisible();
|
||||
const day = await cell.getAttribute("data-day");
|
||||
if (isPhone(testInfo)) {
|
||||
await cell.tap();
|
||||
const open = page.locator(
|
||||
'[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]',
|
||||
);
|
||||
await expect(open).toBeVisible();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
} else {
|
||||
await cell.click();
|
||||
}
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day!,
|
||||
);
|
||||
// The open day's cell is outlined.
|
||||
await expect(page.locator('[data-slot="heatmap-open-day"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("a lab marker's reading opens the day it was taken", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto("/labs");
|
||||
await page
|
||||
.locator('[data-slot="lab-list-analyte"]')
|
||||
.filter({ hasText: DAY_DOORS_ANALYTE })
|
||||
.first()
|
||||
.click();
|
||||
const plot = page
|
||||
.locator('[data-slot="chart-plot"][data-day-links="true"]')
|
||||
.first();
|
||||
await expect(plot).toBeVisible();
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
// The reading on top, with the one before it beside it.
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("a chart with its comparison period on still opens its days", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto("/insights/weight");
|
||||
const plot = page
|
||||
.locator('[data-slot="chart-plot"][data-day-links="true"]')
|
||||
.first();
|
||||
await expect(plot).toBeVisible();
|
||||
await page
|
||||
.locator('[data-slot="chart-overlay-controls-trigger"]')
|
||||
.first()
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-slot="chart-overlay-comparison-lastMonth"]')
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
// The overlay menu is closed before the chart is touched, so the tap
|
||||
// lands on the chart and not on the menu's outside-dismiss.
|
||||
await expect(
|
||||
page.locator('[data-slot="chart-overlay-controls-content"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="chart-compare-caption"]').first(),
|
||||
).toBeAttached();
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the dashboard and the list of all values", () => {
|
||||
test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page, context }, testInfo) => {
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
await mockDay(page, { index: "full" });
|
||||
});
|
||||
|
||||
test("a dashboard chart opens its day; the today hero offers none", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
if (!isPhone(testInfo)) {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
}
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
const plot = page
|
||||
.locator('[data-slot="chart-plot"][data-day-links="true"]')
|
||||
.first();
|
||||
await expect(plot).toBeVisible({ timeout: 20_000 });
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
// Closing leaves the dashboard on today.
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
test("a date in a metric's list of all values opens its day", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto("/insights/values/WEIGHT");
|
||||
const link = page.locator('[data-slot="day-link"]:visible').first();
|
||||
await expect(link).toBeVisible();
|
||||
const day = await link.getAttribute("data-day");
|
||||
if (isPhone(testInfo)) await link.tap();
|
||||
else await link.click();
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day!,
|
||||
);
|
||||
});
|
||||
|
||||
test("the docked day moves the Coach button off its footer", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(isPhone(testInfo), "docked from 1280 px");
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
// The launcher follows the `coach` capability, which needs a provider
|
||||
// the suite cannot reach, so the capability reads available here; this
|
||||
// journey is about where the button sits, not what it opens.
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await page.goto("/insights/weight");
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible();
|
||||
const plot = page
|
||||
.locator('[data-slot="chart-plot"][data-day-links="true"]')
|
||||
.first();
|
||||
await openFromPlot(page, plot, testInfo);
|
||||
const panelBox = (await panel(page).boundingBox())!;
|
||||
// Wait out the 150 ms move, then the launcher sits left of the column.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = (await fab.boundingBox())!;
|
||||
return b.x + b.width;
|
||||
})
|
||||
.toBeLessThanOrEqual(panelBox.x);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the readiness link into a medication's edit form", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test("?edit=1 opens the medication's edit form and leaves the URL", async ({
|
||||
page,
|
||||
}) => {
|
||||
const res = await page.request.get("/api/medications");
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ id: string }> | { medications?: Array<{ id: string }> };
|
||||
};
|
||||
const list = Array.isArray(body.data)
|
||||
? body.data
|
||||
: (body.data.medications ?? []);
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
await page.goto(`/medications/${list[0]!.id}?edit=1`);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
// A reload after closing the form must not open it again.
|
||||
await expect(page).not.toHaveURL(/[?&]edit=1/);
|
||||
});
|
||||
});
|
||||
+13
-87
@@ -1,5 +1,6 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
@@ -20,97 +21,13 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
|
||||
* the future is dropped;
|
||||
* - stepping to the neighbouring days never adds history entries;
|
||||
* - the dashboard's today area offers no door.
|
||||
* - the dashboard's today area offers no door; its charts below do.
|
||||
*
|
||||
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
||||
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
||||
* `day-next` / `day-close`.
|
||||
*/
|
||||
|
||||
function dayFixture(date: string) {
|
||||
return {
|
||||
date,
|
||||
tz: "Europe/Berlin",
|
||||
counts: { values: 2, entries: 1 },
|
||||
running: [
|
||||
{
|
||||
kind: "illness",
|
||||
section: "illness",
|
||||
id: "ep1",
|
||||
title: "Common cold",
|
||||
sub: null,
|
||||
since: date,
|
||||
until: null,
|
||||
dayIndex: 1,
|
||||
dayCount: null,
|
||||
href: "/illness",
|
||||
},
|
||||
],
|
||||
values: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 124,
|
||||
unit: "mmHg",
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
source: "MANUAL",
|
||||
band: { lo: 118, hi: 128, n: 20 },
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 79,
|
||||
unit: "mmHg",
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
source: "MANUAL",
|
||||
band: { lo: 76, hi: 82, n: 20 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
kind: "mood",
|
||||
section: "mood",
|
||||
id: "m1",
|
||||
title: "Mood: good",
|
||||
meta: null,
|
||||
note: null,
|
||||
docs: [],
|
||||
href: "/mood",
|
||||
},
|
||||
],
|
||||
notable: [],
|
||||
sections: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function mockDay(page: Page) {
|
||||
await page.route("**/api/day/**", async (route: Route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/api/day/index") {
|
||||
const from = url.searchParams.get("from") ?? "";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
from,
|
||||
to: url.searchParams.get("to") ?? "",
|
||||
days: {},
|
||||
notable: [],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const date = url.pathname.split("/").pop() ?? "";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: dayFixture(date), error: null }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
@@ -252,6 +169,8 @@ test.describe("the day view", () => {
|
||||
await open.tap();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
// The tapped value heads the day.
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("the dashboard's today area offers no day door", async ({ page }) => {
|
||||
@@ -259,8 +178,15 @@ test.describe("the day view", () => {
|
||||
await expect(
|
||||
page.locator('[data-slot="main-content-wrapper"]'),
|
||||
).toBeVisible();
|
||||
// The hero is today: nothing in it opens another day. (The charts
|
||||
// further down do; `day-doors.spec.ts` walks one.)
|
||||
await expect(
|
||||
page.locator('[data-slot="chart-plot"][data-day-links="true"]'),
|
||||
page.locator('[data-slot^="today-hero"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The record `day-doors.spec.ts` opens days from.
|
||||
*
|
||||
* Every chart the journey clicks needs points to click: ten days of the
|
||||
* autonomic charge (the recovery page), of sleep and of mood (the sleep
|
||||
* chart, the mood line and the mood calendar), weight over the last ten days
|
||||
* and the same ten days a month earlier (the comparison chart draws a prior
|
||||
* period only where one exists), and two readings of one lab marker.
|
||||
*
|
||||
* Written through the app's own routes, once, from `globalSetup`. An account
|
||||
* that already holds the lab marker is taken as seeded, so a local re-run
|
||||
* against a reused database does not double every row.
|
||||
*/
|
||||
import { request as playwrightRequest } from "@playwright/test";
|
||||
|
||||
export const DAY_DOORS_ANALYTE = "Ferritin";
|
||||
|
||||
export async function seedDayDoorsRecord(
|
||||
baseURL: string,
|
||||
storageStatePath: string,
|
||||
): Promise<void> {
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: storageStatePath,
|
||||
});
|
||||
try {
|
||||
const labs = await ctx.get("/api/labs");
|
||||
const body = (await labs.json()) as { data: unknown };
|
||||
if (JSON.stringify(body.data ?? null).includes(DAY_DOORS_ANALYTE)) return;
|
||||
|
||||
const post = async (path: string, data: unknown): Promise<void> => {
|
||||
const res = await ctx.post(path, { data });
|
||||
if (res.status() === 409 || res.ok()) return;
|
||||
throw new Error(
|
||||
`[day-doors-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
};
|
||||
const daysAgo = (days: number, hour = 8) => {
|
||||
const at = new Date(Date.now() - days * 86_400_000);
|
||||
at.setUTCHours(hour, 0, 0, 0);
|
||||
return at.toISOString();
|
||||
};
|
||||
|
||||
for (let day = 1; day <= 10; day += 1) {
|
||||
await post("/api/measurements", {
|
||||
type: "ANS_CHARGE",
|
||||
value: 5 + day,
|
||||
measuredAt: daysAgo(day, 5),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "SLEEP_DURATION",
|
||||
value: 400 + day * 4,
|
||||
measuredAt: daysAgo(day, 6),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: 72 + day / 10,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
// The same ten days, a month before: the prior period the comparison
|
||||
// draws beside them.
|
||||
await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: 73 + day / 10,
|
||||
measuredAt: daysAgo(day + 30, 7),
|
||||
});
|
||||
await post("/api/mood-entries", {
|
||||
mood: day % 2 ? "GUT" : "OKAY",
|
||||
moodLoggedAt: daysAgo(day, 19),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [days, value] of [
|
||||
[60, 41],
|
||||
[4, 58],
|
||||
] as const) {
|
||||
await post("/api/labs", {
|
||||
analyte: DAY_DOORS_ANALYTE,
|
||||
panel: "Eisenstoffwechsel",
|
||||
value,
|
||||
unit: "ng/mL",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 300,
|
||||
takenAt: daysAgo(days),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The day routes, answered in the contract's shape.
|
||||
*
|
||||
* The day itself (`GET /api/day/{date}`) and the index (`GET /api/day/index`)
|
||||
* are route mocks, so a journey pins the client: which door opens which day,
|
||||
* what the history does, where no door is. The server's own answers are
|
||||
* pinned by the integration suite.
|
||||
*/
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
function dayFixture(date: string) {
|
||||
return {
|
||||
date,
|
||||
tz: "Europe/Berlin",
|
||||
counts: { values: 2, entries: 1 },
|
||||
running: [
|
||||
{
|
||||
kind: "illness",
|
||||
section: "illness",
|
||||
id: "ep1",
|
||||
title: "Common cold",
|
||||
sub: null,
|
||||
since: date,
|
||||
until: null,
|
||||
dayIndex: 1,
|
||||
dayCount: null,
|
||||
href: "/illness",
|
||||
},
|
||||
],
|
||||
values: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 124,
|
||||
unit: "mmHg",
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
source: "MANUAL",
|
||||
band: { lo: 118, hi: 128, n: 20 },
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 79,
|
||||
unit: "mmHg",
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
source: "MANUAL",
|
||||
band: { lo: 76, hi: 82, n: 20 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
kind: "mood",
|
||||
section: "mood",
|
||||
id: "m1",
|
||||
title: "Mood: good",
|
||||
meta: null,
|
||||
note: null,
|
||||
docs: [],
|
||||
href: "/mood",
|
||||
},
|
||||
],
|
||||
notable: [],
|
||||
sections: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day from `from` to `to`, each holding values: what the row of day
|
||||
* dots under a chart reads when the whole window has entries.
|
||||
*/
|
||||
function everyDay(from: string, to: string): Record<string, string[]> {
|
||||
const days: Record<string, string[]> = {};
|
||||
if (!from || !to) return days;
|
||||
const at = new Date(`${from}T12:00:00.000Z`);
|
||||
const end = new Date(`${to}T12:00:00.000Z`);
|
||||
while (at <= end) {
|
||||
days[at.toISOString().slice(0, 10)] = ["values"];
|
||||
at.setUTCDate(at.getUTCDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
export async function mockDay(
|
||||
page: Page,
|
||||
options: { index?: "empty" | "full" } = {},
|
||||
) {
|
||||
await page.route("**/api/day/**", async (route: Route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/api/day/index") {
|
||||
const from = url.searchParams.get("from") ?? "";
|
||||
const to = url.searchParams.get("to") ?? "";
|
||||
const days = options.index === "full" ? everyDay(from, to) : {};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
from,
|
||||
to,
|
||||
days,
|
||||
// The first day of the window stands out: the rug draws a ring.
|
||||
notable: Object.keys(days).slice(0, 1),
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const date = url.pathname.split("/").pop() ?? "";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: dayFixture(date), error: null }),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { seedDayDoorsRecord } from "./day-doors-fixture";
|
||||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||||
|
||||
// Resolve relative to the project root rather than __dirname/import.meta.url —
|
||||
@@ -249,6 +250,26 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateTimeline.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The day-doors journey's own account (`day-doors.spec.ts`).
|
||||
*
|
||||
* It turns a chart's comparison overlay on, which is a per-account
|
||||
* preference; on a shared account a parallel spec would find its chart
|
||||
* drawing a prior period it never asked for. Its record carries the series
|
||||
* the journey clicks (recovery, sleep, mood, weight, a lab marker).
|
||||
*/
|
||||
export const E2E_DAY_DOORS = {
|
||||
email: "e2e-day-doors@healthlog.test",
|
||||
username: "e2e-day-doors",
|
||||
password: "Dy4!Rk8wQm2vTz6P",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const DAY_DOORS_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateDayDoors.json",
|
||||
);
|
||||
|
||||
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateAiOptional.json",
|
||||
@@ -950,6 +971,30 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The day-doors journey's account. Every module the journey reaches is
|
||||
// on; its record is written after the login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_DAY_DOORS.username,
|
||||
E2E_DAY_DOORS.email,
|
||||
await hashPassword(E2E_DAY_DOORS.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// The notification-dispatch journey's account. Seeded like the others;
|
||||
// its channels, devices, ledger rows and preferences are reset by
|
||||
// `e2e/setup/notification-fixture.ts` before every test, because the
|
||||
@@ -1556,6 +1601,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// The timeline journey's jar — see `E2E_TIMELINE`.
|
||||
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
|
||||
|
||||
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
|
||||
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function InsightsStimmungPage() {
|
||||
MeasurementType series, so the period-over-period range read
|
||||
(`/api/analytics/range`, keyed on a MeasurementType enum) has
|
||||
nothing to aggregate. */}
|
||||
<MoodChart chartKey="mood" compareBaseline={compareBaseline} />
|
||||
<MoodChart chartKey="mood" compareBaseline={compareBaseline} dayLinks />
|
||||
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
|
||||
@@ -1693,6 +1693,7 @@ export default function DashboardPageClient({
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={unitDisplay.transformFor("WEIGHT").factor}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1746,6 +1747,7 @@ export default function DashboardPageClient({
|
||||
},
|
||||
]}
|
||||
compareBaseline={compareBaseline}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1772,6 +1774,7 @@ export default function DashboardPageClient({
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1800,6 +1803,7 @@ export default function DashboardPageClient({
|
||||
valueBands={hasRestingHr ? pulseBands : undefined}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1824,6 +1828,7 @@ export default function DashboardPageClient({
|
||||
valueBands={bodyFatBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1840,6 +1845,7 @@ export default function DashboardPageClient({
|
||||
onDataReady={() => markChartReady("mood-chart")}
|
||||
compareBaseline={compareBaseline}
|
||||
chartKey="mood"
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1861,6 +1867,7 @@ export default function DashboardPageClient({
|
||||
unit="h"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1883,6 +1890,7 @@ export default function DashboardPageClient({
|
||||
colors={["var(--success)"]}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1903,6 +1911,7 @@ export default function DashboardPageClient({
|
||||
onDataReady={() => markChartReady("medications")}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -186,8 +191,13 @@ export function ChartDataTable({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((point) => (
|
||||
<TableRow key={point.timestamp} data-slot="chart-data-table-row">
|
||||
{rows.map((point, index) => (
|
||||
// Two readings can share a day (a list of entries, not of
|
||||
// days), so the timestamp alone is not a key.
|
||||
<TableRow
|
||||
key={`${point.timestamp}-${index}`}
|
||||
data-slot="chart-data-table-row"
|
||||
>
|
||||
{/* The date is the row's header, so a screen reader reads
|
||||
"12 July, systolic 128" rather than a bare number. */}
|
||||
<TableHead
|
||||
@@ -224,7 +234,7 @@ export function ChartDataTable({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
formatValue(numeric)
|
||||
(column.format ?? formatValue)(numeric)
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { openDay } from "@/components/day/day-layer-controller";
|
||||
import { isOpenableDay } from "@/components/day/day-url";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import { ChartDayCaption } from "@/components/day/chart-day";
|
||||
import { dayAnchor } from "@/components/day/chart-day-links";
|
||||
import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import { heatmapDays } from "@/lib/charts/heatmap-days";
|
||||
@@ -90,59 +92,10 @@ export function ComplianceHeatmap({
|
||||
const formatDay = useCalendarDate();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
/**
|
||||
* v1.4.27 MB7 / CF-10 — when `pinned` the tooltip stays mounted
|
||||
* across `onPointerLeave` so a touch user sees the per-cell
|
||||
* breakdown after lifting their finger. Mouse + pen users get the
|
||||
* existing hover-only experience because their interactions never
|
||||
* set `pinned`. A second tap on a different cell repositions the
|
||||
* tooltip; a tap outside any cell clears it (wired below).
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/** v1.42 — the day a pinned (touch) tooltip offers to open. */
|
||||
day?: string;
|
||||
} | null>(null);
|
||||
// v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
|
||||
// which then offers the day. The pointer type of the press decides.
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
|
||||
// v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a
|
||||
// touch user can clear the per-cell detail without scrolling the
|
||||
// pinned label off-screen. The listener is gated on `tooltip?.pinned`
|
||||
// so the non-touch hover flow never pays the indirection cost.
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) {
|
||||
setTooltip(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
// 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip
|
||||
// (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's
|
||||
// "dismissible" requirement.
|
||||
useEffect(() => {
|
||||
if (!tooltip) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setTooltip(null);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [tooltip]);
|
||||
// v1.42 — the cells are doors to their days, the same doors every chart
|
||||
// has: a click (fine pointer), the pinned tooltip's button (touch), a
|
||||
// dashed outline on the open day, and the data table for the keyboard.
|
||||
const heatmapDay = useHeatmapDay(containerRef);
|
||||
|
||||
const WEEKDAY_LABELS = [
|
||||
t("charts.weekdays.mon"),
|
||||
@@ -262,9 +215,8 @@ export function ComplianceHeatmap({
|
||||
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
|
||||
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
|
||||
|
||||
// Per-day text alternative, shared by the pointer tooltip and the
|
||||
// visually-hidden day list below the grid (M2). One source of truth so
|
||||
// the sr-only list never drifts from what a pointer user reads.
|
||||
// Per-day text for the pointer tooltip; the data table below the grid
|
||||
// carries each active day's rate (M2).
|
||||
const describeCell = (cell: (typeof cells)[number]): string => {
|
||||
const rate =
|
||||
cell.data.expected > 0
|
||||
@@ -282,8 +234,8 @@ export function ComplianceHeatmap({
|
||||
: "";
|
||||
return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
|
||||
};
|
||||
// Only days that carried a scheduled or taken dose go into the sr-only
|
||||
// list; empty days are covered by the aggregate summary on the SVG.
|
||||
// Only days that carried a scheduled or taken dose go into the data
|
||||
// table; empty days are covered by the aggregate summary on the SVG.
|
||||
const activeCells = cells.filter(
|
||||
(cell) => cell.data.expected > 0 || cell.data.taken > 0,
|
||||
);
|
||||
@@ -313,9 +265,7 @@ export function ComplianceHeatmap({
|
||||
// off `containerWidth`, so a full window still fills the row) rather
|
||||
// than CSS-stretching ~5 columns of capped cells into wide rectangles.
|
||||
className={stretch ? "block max-w-full" : "block"}
|
||||
onMouseLeave={() =>
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null))
|
||||
}
|
||||
{...heatmapDay.svgProps}
|
||||
>
|
||||
{/* Month labels */}
|
||||
{monthMarkers.map((m, i) => (
|
||||
@@ -354,114 +304,47 @@ export function ComplianceHeatmap({
|
||||
users tap the cell to pin and tap outside (or another
|
||||
cell) to move/clear. `pointerType === "touch"` discriminates
|
||||
so a hover dismiss never wipes a pinned tooltip. */}
|
||||
{cells.map((cell) => {
|
||||
const buildText = (): string => describeCell(cell);
|
||||
return (
|
||||
{cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the data table below the grid, not
|
||||
// through the 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 on a year-long grid.
|
||||
// The rects stay a pure pointer affordance.
|
||||
{...heatmapDay.cellProps({
|
||||
dateKey: cell.dateKey,
|
||||
hasEntry: cell.data.expected > 0 || cell.data.taken > 0,
|
||||
describe: () => describeCell(cell),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{cells
|
||||
.filter((cell) => cell.dateKey === heatmapDay.openKey)
|
||||
.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
data-day={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
className="cursor-pointer"
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the visually-hidden day list below
|
||||
// the grid, not through the 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 on a
|
||||
// year-long grid. The rects stay a pure pointer affordance.
|
||||
onPointerEnter={(e) => {
|
||||
// Touch enter fires synthetically immediately before
|
||||
// `pointerdown`; skip it so the pinned tooltip below
|
||||
// takes precedence with its real coordinates.
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.data.expected > 0 &&
|
||||
isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.data.expected === 0) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
key={`open-${cell.dateKey}`}
|
||||
data-slot="heatmap-open-day"
|
||||
x={labelWidth + cell.col * step - 1.5}
|
||||
y={headerHeight + cell.row * step - 1.5}
|
||||
width={cellSize + 3}
|
||||
height={cellSize + 3}
|
||||
rx={3}
|
||||
pointerEvents="none"
|
||||
{...OPEN_DAY_CELL}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
|
||||
carries an aggregate `role="img"` summary; this list is the
|
||||
keyboard/screen-reader path to the same granular values the pointer
|
||||
tooltip shows, without adding a tab stop per cell. */}
|
||||
{activeCells.length > 0 && (
|
||||
<ul className="sr-only" data-slot="compliance-heatmap-day-list">
|
||||
{activeCells.map((cell) => (
|
||||
<li key={cell.dateKey}>{describeCell(cell)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
data-slot="compliance-heatmap-tooltip"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
left:
|
||||
typeof window !== "undefined"
|
||||
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
|
||||
: tooltip.x + 10,
|
||||
top: tooltip.y - 30,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="heatmap-open-day"
|
||||
onClick={() => {
|
||||
const day = tooltip.day;
|
||||
setTooltip(null);
|
||||
if (day) openDay(day, { trigger: containerRef.current });
|
||||
}}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.openDay")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{heatmapDay.tooltipNode}
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
@@ -483,6 +366,42 @@ export function ComplianceHeatmap({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{activeCells.length > 0 ? (
|
||||
<>
|
||||
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
|
||||
{/* The keyboard and screen-reader way to each active day's doses,
|
||||
and to the day itself. */}
|
||||
<ChartDataTable
|
||||
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(
|
||||
100,
|
||||
Math.round((cell.data.taken / cell.data.expected) * 100),
|
||||
)
|
||||
: undefined,
|
||||
}))}
|
||||
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")}
|
||||
dayLinks
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,15 +77,12 @@ import {
|
||||
} from "@/lib/charts/window-stats";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
|
||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||
import {
|
||||
ChartDayCaption,
|
||||
DayRug,
|
||||
TooltipDayAction,
|
||||
chartPointDayKey,
|
||||
openChartDay,
|
||||
useCoarsePointer,
|
||||
} from "@/components/day/chart-day";
|
||||
import { useOpenDay } from "@/components/day/day-layer-controller";
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { isWholeNumberType } from "@/components/day/use-day-value-format";
|
||||
|
||||
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
|
||||
@@ -1732,19 +1729,10 @@ export function HealthChart({
|
||||
// v1.42 — the chart as a door to its days (`dayLinks`). Daily points only:
|
||||
// a week or month point averages many days and has no one day to open.
|
||||
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
|
||||
const openDayKey = useOpenDay();
|
||||
const coarsePointer = useCoarsePointer();
|
||||
const plotRef = useRef<HTMLDivElement>(null);
|
||||
const openDayIndex =
|
||||
dayLinksActive && openDayKey
|
||||
? resolveVerticalMarkerPositions([{ date: openDayKey }], chartData)[0]
|
||||
?.pointIndex
|
||||
: undefined;
|
||||
const openDayPoint =
|
||||
openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
|
||||
// What the day shows at its top when it is opened from a point: the
|
||||
// chart's own name and the point's value as the tooltip reads it.
|
||||
const dayFocusFor = (point: ChartDataPoint) => {
|
||||
const dayFocusFor = (point: ChartDataPoint | undefined) => {
|
||||
if (!point) return null;
|
||||
const parts = types
|
||||
.map((type) => ({ type, v: point[type] }))
|
||||
.filter((p): p is { type: string; v: number } => typeof p.v === "number");
|
||||
@@ -1764,10 +1752,17 @@ export function HealthChart({
|
||||
types: valueMode === "raw" ? types : [],
|
||||
};
|
||||
};
|
||||
const openPointDay = (point: ChartDataPoint | undefined) => {
|
||||
if (!point) return;
|
||||
openChartDay(point.timestamp, dayFocusFor(point), plotRef.current);
|
||||
};
|
||||
const dayLinkSource = chartDataWithCompare ?? chartData;
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: dayLinksActive,
|
||||
days: dayLinksActive
|
||||
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
|
||||
: [],
|
||||
focusFor: (index) => dayFocusFor(dayLinkSource?.[index]),
|
||||
});
|
||||
const openDayIndex = chartDays.openIndex;
|
||||
const openDayPoint =
|
||||
openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
|
||||
|
||||
const showContextDetails = showMA || showTrend || showBands;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
@@ -2025,11 +2020,8 @@ export function HealthChart({
|
||||
// gate on; this slot is rendered by the data branch only, so it
|
||||
// says the same thing without depending on Recharts' markup.
|
||||
data-slot="chart-plot"
|
||||
data-day-links={dayLinksActive ? "true" : undefined}
|
||||
ref={plotRef}
|
||||
className={`relative z-10 h-full touch-pan-y ${
|
||||
dayLinksActive && !coarsePointer ? "cursor-pointer" : ""
|
||||
}`}
|
||||
{...chartDays.plotProps}
|
||||
className={`relative z-10 h-full touch-pan-y ${chartDays.plotClassName}`}
|
||||
role="img"
|
||||
aria-label={chartAriaLabel}
|
||||
>
|
||||
@@ -2038,17 +2030,7 @@ export function HealthChart({
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
accessibilityLayer
|
||||
onClick={
|
||||
dayLinksActive && !coarsePointer
|
||||
? (state) => {
|
||||
const index = Number(state?.activeTooltipIndex);
|
||||
if (!Number.isInteger(index)) return;
|
||||
openPointDay(
|
||||
(chartDataWithCompare ?? chartData)?.[index],
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -2218,10 +2200,7 @@ export function HealthChart({
|
||||
<ReferenceLine
|
||||
key="open-day"
|
||||
x={openDayIndex}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.7}
|
||||
ifOverflow="discard"
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
{openDayIndex !== undefined && openDayPoint
|
||||
@@ -2281,14 +2260,7 @@ export function HealthChart({
|
||||
filterNull={false}
|
||||
// v1.42 — on a touch screen the tooltip is the way to the
|
||||
// day: it stays where the tap put it and takes taps.
|
||||
trigger={
|
||||
dayLinksActive && coarsePointer ? "click" : "hover"
|
||||
}
|
||||
wrapperStyle={
|
||||
dayLinksActive && coarsePointer
|
||||
? { pointerEvents: "auto", zIndex: 20 }
|
||||
: undefined
|
||||
}
|
||||
{...chartDays.tooltipProps}
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
@@ -2432,13 +2404,11 @@ export function HealthChart({
|
||||
: dateLabel
|
||||
}
|
||||
rows={rows}
|
||||
action={
|
||||
dayLinksActive && coarsePointer && hoverPoint ? (
|
||||
<TooltipDayAction
|
||||
onOpen={() => openPointDay(hoverPoint)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint
|
||||
? dayLinkSource?.indexOf(hoverPoint)
|
||||
: undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -2574,17 +2544,15 @@ export function HealthChart({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{dayLinksActive && chartData && chartData.length > 0 ? (
|
||||
<>
|
||||
<DayRug
|
||||
points={chartData}
|
||||
// The plot's margin (8) + the y axis + the x axis padding
|
||||
// (10) on the left, margin + padding on the right.
|
||||
insetLeft={8 + yAxisWidth + 10}
|
||||
insetRight={8 + 10}
|
||||
/>
|
||||
<ChartDayCaption coarse={coarsePointer} />
|
||||
</>
|
||||
{chartData ? (
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={chartData}
|
||||
// The plot's margin (8) + the y axis + the x axis padding
|
||||
// (10) on the left, margin + padding on the right.
|
||||
insetLeft={8 + yAxisWidth + 10}
|
||||
insetRight={8 + 10}
|
||||
/>
|
||||
) : null}
|
||||
{/* The points the chart just drew, as a table. Reads the SAME
|
||||
expression `<ComposedChart data>` is handed above, so the two
|
||||
|
||||
@@ -70,6 +70,12 @@ import { chooseTickInterval } from "@/lib/charts/x-axis-density";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
|
||||
interface DailyCompliancePoint {
|
||||
/** Berlin calendar day, "YYYY-MM-DD". */
|
||||
@@ -205,6 +211,11 @@ interface MedicationComplianceChartProps {
|
||||
* Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
/**
|
||||
* v1.42 — each day's point opens that day, through the doors every
|
||||
* day-linked chart has (`useChartDayLinks`).
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
export function MedicationComplianceChart({
|
||||
@@ -212,6 +223,7 @@ export function MedicationComplianceChart({
|
||||
userTimezone = DEFAULT_TIMEZONE,
|
||||
compareBaseline,
|
||||
onDataReady,
|
||||
dayLinks = false,
|
||||
}: MedicationComplianceChartProps) {
|
||||
// v1.4.27 R4 RC3 — the prop is accepted so the dashboard can pass
|
||||
// `compareBaseline={compareBaseline}` uniformly across every chart.
|
||||
@@ -343,6 +355,17 @@ export function MedicationComplianceChart({
|
||||
};
|
||||
|
||||
const displayTitle = title ?? t("dashboard.medications");
|
||||
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: dayLinks && chartData.length >= 3,
|
||||
days: chartData.map((point) => chartPointDayKey(point.timestamp)),
|
||||
focusFor: (index) => {
|
||||
const point = chartData[index];
|
||||
return point
|
||||
? { label: displayTitle, value: `${fmt.integer(point.rate)} %` }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
@@ -535,121 +558,155 @@ export function MedicationComplianceChart({
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]"
|
||||
role="img"
|
||||
aria-label={complianceAriaLabel}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={chooseTickInterval(chartData.length, viewportWidth)}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={yAxisFormatter}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={(props) => {
|
||||
const { active, payload } = props as unknown as {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value?: number;
|
||||
color?: string;
|
||||
payload?: ChartPoint;
|
||||
}>;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts = payload[0]?.payload?.timestamp;
|
||||
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
|
||||
const rate = payload[0]?.value;
|
||||
if (typeof rate !== "number") return null;
|
||||
// Delta vs. the 100 % goal — a positive delta means
|
||||
// "100 % target hit" and reads as success; a
|
||||
// negative delta is the gap to close.
|
||||
const gap = 100 - rate;
|
||||
let delta: string | undefined;
|
||||
if (gap < 0.5) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const formatted = `−${fmt.integer(gap)} pp`;
|
||||
delta = t("charts.deltaVsTarget").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]",
|
||||
chartDays.plotClassName,
|
||||
)}
|
||||
role="img"
|
||||
aria-label={complianceAriaLabel}
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={chooseTickInterval(chartData.length, viewportWidth)}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={yAxisFormatter}
|
||||
/>
|
||||
{chartDays.openIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
x={chartData[chartDays.openIndex]?.date}
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={(props) => {
|
||||
const { active, payload } = props as unknown as {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value?: number;
|
||||
color?: string;
|
||||
payload?: ChartPoint;
|
||||
}>;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts = payload[0]?.payload?.timestamp;
|
||||
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
|
||||
const rate = payload[0]?.value;
|
||||
if (typeof rate !== "number") return null;
|
||||
// Delta vs. the 100 % goal — a positive delta means
|
||||
// "100 % target hit" and reads as success; a
|
||||
// negative delta is the gap to close.
|
||||
const gap = 100 - rate;
|
||||
let delta: string | undefined;
|
||||
if (gap < 0.5) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const formatted = `−${fmt.integer(gap)} pp`;
|
||||
delta = t("charts.deltaVsTarget").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
);
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("dashboard.compliance7d"),
|
||||
value: `${fmt.integer(rate)} %`,
|
||||
color: payload[0]?.color ?? COLOR_LINE,
|
||||
delta,
|
||||
},
|
||||
];
|
||||
const hoverPoint = payload[0]?.payload;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint
|
||||
? chartData.indexOf(hoverPoint)
|
||||
: undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("dashboard.compliance7d"),
|
||||
value: `${fmt.integer(rate)} %`,
|
||||
color: payload[0]?.color ?? COLOR_LINE,
|
||||
delta,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip active label={dateLabel} rows={rows} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* v1.4.18 — minimum-acceptable threshold + goal line
|
||||
}}
|
||||
/>
|
||||
{/* v1.4.18 — minimum-acceptable threshold + goal line
|
||||
are now opt-in via the "Target range" overlay
|
||||
toggle. Default OFF; chart renders as a clean line
|
||||
until the user activates the overlay from the
|
||||
settings popover. */}
|
||||
{showTargetRange ? (
|
||||
<>
|
||||
<ReferenceLine
|
||||
y={80}
|
||||
stroke={COLOR_THRESHOLD}
|
||||
strokeDasharray="3 5"
|
||||
strokeOpacity={0.6}
|
||||
data-slot="medication-threshold-line"
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={100}
|
||||
stroke={COLOR_GOAL}
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.85}
|
||||
data-slot="medication-goal-line"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke={COLOR_LINE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2, fill: COLOR_LINE }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{showTargetRange ? (
|
||||
<>
|
||||
<ReferenceLine
|
||||
y={80}
|
||||
stroke={COLOR_THRESHOLD}
|
||||
strokeDasharray="3 5"
|
||||
strokeOpacity={0.6}
|
||||
data-slot="medication-threshold-line"
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={100}
|
||||
stroke={COLOR_GOAL}
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.85}
|
||||
data-slot="medication-goal-line"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke={COLOR_LINE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2, fill: COLOR_LINE }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={chartData}
|
||||
axis="band"
|
||||
// The margin (8) + the y axis (48) on the left, the margin (8)
|
||||
// on the right.
|
||||
insetLeft={8 + 48}
|
||||
insetRight={8}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
type ChartBucketType,
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
@@ -102,6 +109,14 @@ interface MoodChartProps {
|
||||
* visibly before its measurement siblings. Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
/**
|
||||
* v1.42 — the chart is a door to its days, exactly as `HealthChart`'s
|
||||
* `dayLinks`: a click on a point (the tooltip's button on touch) opens the
|
||||
* day, the open day carries a dashed line, a row of dots marks the days
|
||||
* with entries, and the data table under the chart links each date. Daily
|
||||
* points only, never in `mini` mode.
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
@@ -317,6 +332,7 @@ export function MoodChart({
|
||||
compareBaseline = "none",
|
||||
chartKey,
|
||||
onDataReady,
|
||||
dayLinks = false,
|
||||
}: MoodChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -654,6 +670,27 @@ export function MoodChart({
|
||||
: sorted[mid];
|
||||
}, [chartData]);
|
||||
|
||||
// v1.42 — the chart as a door to its days (`dayLinks`), through the same
|
||||
// hook every day-linked chart uses.
|
||||
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
|
||||
const dayLinkSource = chartDataWithCompare ?? chartData;
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: dayLinksActive,
|
||||
days: dayLinksActive
|
||||
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
|
||||
: [],
|
||||
focusFor: (index) => {
|
||||
const point = dayLinkSource?.[index];
|
||||
return point
|
||||
? {
|
||||
label: displayTitle,
|
||||
value: formatTooltipValue(point.score),
|
||||
types: ["MOOD"],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// v1.16.8 — only an EMPTY success hides the card (the dashboard gates
|
||||
@@ -766,7 +803,8 @@ export function MoodChart({
|
||||
mini
|
||||
? "h-[var(--chart-height,140px)]"
|
||||
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
|
||||
} touch-pan-y`}
|
||||
} touch-pan-y ${chartDays.plotClassName}`}
|
||||
{...chartDays.plotProps}
|
||||
// Same stable hook as the measurement chart: rendered by the data
|
||||
// branch only, so the browser suite can gate on it instead of on
|
||||
// Recharts' own class names.
|
||||
@@ -779,6 +817,7 @@ export function MoodChart({
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
accessibilityLayer
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
|
||||
five mood-score ticks ([1,2,3,4,5]), which Recharts
|
||||
@@ -868,7 +907,15 @@ export function MoodChart({
|
||||
tickMargin={10}
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
{chartDays.openIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
key="open-day"
|
||||
x={chartDays.openIndex}
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
@@ -968,7 +1015,16 @@ export function MoodChart({
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return <RichChartTooltip active label={dateLabel} rows={rows} />;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
@@ -1142,7 +1198,36 @@ export function MoodChart({
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{chartBody}</CardContent>
|
||||
<CardContent>
|
||||
{chartBody}
|
||||
{!isLoading && !isError && chartData && chartData.length >= 3 ? (
|
||||
<>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={chartData}
|
||||
// The plot's margin (8) + the y axis (65) + the x axis padding
|
||||
// (10) on the left, margin + padding on the right.
|
||||
insetLeft={8 + 65 + 10}
|
||||
insetRight={8 + 10}
|
||||
/>
|
||||
{dayLinksActive ? (
|
||||
<ChartDataTable
|
||||
points={chartData.map((point) => ({
|
||||
date: point.date,
|
||||
timestamp: point.timestamp,
|
||||
score: point.score,
|
||||
}))}
|
||||
columns={[{ key: "score", label: t("charts.moodScore") }]}
|
||||
formatValue={formatTooltipValue}
|
||||
formatDate={tzFmt.date}
|
||||
bucket="day"
|
||||
metricLabel={displayTitle}
|
||||
dayLinks
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { openDay } from "@/components/day/day-layer-controller";
|
||||
import { isOpenableDay } from "@/components/day/day-url";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import { ChartDayCaption } from "@/components/day/chart-day";
|
||||
import { dayAnchor } from "@/components/day/chart-day-links";
|
||||
import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import { heatmapDays } from "@/lib/charts/heatmap-days";
|
||||
@@ -73,47 +75,10 @@ export function MoodHeatmap({
|
||||
const formatDay = useCalendarDate();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
pinned?: boolean;
|
||||
/** v1.42 — the day a pinned (touch) tooltip offers to open. */
|
||||
day?: string;
|
||||
} | null>(null);
|
||||
// v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
|
||||
// which then offers the day. The pointer type of the press decides.
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) {
|
||||
setTooltip(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
// 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip
|
||||
// (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's
|
||||
// "dismissible" requirement.
|
||||
useEffect(() => {
|
||||
if (!tooltip) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setTooltip(null);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [tooltip]);
|
||||
// v1.42 — the cells are doors to their days, the same doors every chart
|
||||
// has: a click (fine pointer), the pinned tooltip's button (touch), a
|
||||
// dashed outline on the open day, and the data table for the keyboard.
|
||||
const heatmapDay = useHeatmapDay(containerRef);
|
||||
|
||||
const WEEKDAY_LABELS = [
|
||||
t("charts.weekdays.mon"),
|
||||
@@ -225,9 +190,8 @@ export function MoodHeatmap({
|
||||
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
|
||||
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
|
||||
|
||||
// Per-day text alternative, shared by the pointer tooltip and the
|
||||
// visually-hidden day list below the grid (M2). One source of truth so
|
||||
// the sr-only list never drifts from what a pointer user reads.
|
||||
// Per-day text for the pointer tooltip; the data table below the grid
|
||||
// prints the same score with the same label (M2).
|
||||
const describeCell = (cell: (typeof cells)[number]): string => {
|
||||
if (!cell.cell) {
|
||||
return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
|
||||
@@ -236,7 +200,7 @@ export function MoodHeatmap({
|
||||
const moodLabel = labelKey ? t(labelKey) : "";
|
||||
return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
|
||||
};
|
||||
// Only the logged days go into the sr-only list; the empty-tinted cells
|
||||
// Only the logged days go into the data table; the empty-tinted cells
|
||||
// are already covered by the aggregate summary on the SVG, so a rotor
|
||||
// user hears the days that carry a value rather than a wall of "no entry".
|
||||
const loggedCells = cells.filter((cell) => cell.cell !== null);
|
||||
@@ -259,9 +223,7 @@ export function MoodHeatmap({
|
||||
// natural, square, left-aligned grid rather than CSS-stretching a few
|
||||
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
|
||||
className={stretch ? "block max-w-full" : "block"}
|
||||
onMouseLeave={() =>
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null))
|
||||
}
|
||||
{...heatmapDay.svgProps}
|
||||
>
|
||||
{monthMarkers.map((m, i) => (
|
||||
<text
|
||||
@@ -292,113 +254,51 @@ export function MoodHeatmap({
|
||||
),
|
||||
)}
|
||||
|
||||
{cells.map((cell) => {
|
||||
const buildText = (): string => describeCell(cell);
|
||||
return (
|
||||
{cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the data table below the grid, not
|
||||
// through the 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 on a year-long grid.
|
||||
// The rects stay a pure pointer affordance.
|
||||
{...heatmapDay.cellProps({
|
||||
dateKey: cell.dateKey,
|
||||
hasEntry: cell.cell !== null,
|
||||
describe: () => describeCell(cell),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{cells
|
||||
.filter((cell) => cell.dateKey === heatmapDay.openKey)
|
||||
.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
className="cursor-pointer"
|
||||
data-day={cell.dateKey}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the visually-hidden day list below
|
||||
// the grid, not through the 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 on a
|
||||
// year-long grid. The rects stay a pure pointer affordance.
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.cell !== null && isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.cell === null) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
key={`open-${cell.dateKey}`}
|
||||
data-slot="heatmap-open-day"
|
||||
x={labelWidth + cell.col * step - 1.5}
|
||||
y={headerHeight + cell.row * step - 1.5}
|
||||
width={cellSize + 3}
|
||||
height={cellSize + 3}
|
||||
rx={3}
|
||||
pointerEvents="none"
|
||||
{...OPEN_DAY_CELL}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
|
||||
carries an aggregate `role="img"` summary; this list is the
|
||||
keyboard/screen-reader path to the same granular values the pointer
|
||||
tooltip shows, without adding a tab stop per cell. */}
|
||||
{loggedCells.length > 0 && (
|
||||
<ul className="sr-only" data-slot="mood-heatmap-day-list">
|
||||
{loggedCells.map((cell) => (
|
||||
<li key={cell.dateKey}>{describeCell(cell)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
data-slot="mood-heatmap-tooltip"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
left:
|
||||
typeof window !== "undefined"
|
||||
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
|
||||
: tooltip.x + 10,
|
||||
top: tooltip.y - 30,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="heatmap-open-day"
|
||||
onClick={() => {
|
||||
const day = tooltip.day;
|
||||
setTooltip(null);
|
||||
if (day) openDay(day, { trigger: containerRef.current });
|
||||
}}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.openDay")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{heatmapDay.tooltipNode}
|
||||
|
||||
<div
|
||||
className="text-muted-foreground mt-2 flex flex-wrap items-center gap-3 text-xs"
|
||||
@@ -428,6 +328,31 @@ export function MoodHeatmap({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{loggedCells.length > 0 ? (
|
||||
<>
|
||||
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
|
||||
{/* The keyboard and screen-reader way to each logged day's value,
|
||||
and to the day itself. */}
|
||||
<ChartDataTable
|
||||
points={loggedCells.map((cell) => ({
|
||||
date: cell.dateKey,
|
||||
timestamp: dayAnchor(cell.dateKey),
|
||||
score: cell.cell?.score,
|
||||
}))}
|
||||
columns={[{ key: "score", label: t("charts.moodScore") }]}
|
||||
formatValue={(value) => {
|
||||
const labelKey = moodLabelKeyForScore(Math.round(value));
|
||||
return labelKey
|
||||
? `${value.toFixed(1)} · ${t(labelKey)}`
|
||||
: value.toFixed(1);
|
||||
}}
|
||||
formatDate={(date) => formatDay(dateOnlyKey(date))}
|
||||
bucket="day"
|
||||
metricLabel={t("insights.mood.heatmapTitle")}
|
||||
dayLinks
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,17 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
dayAnchor,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
|
||||
/**
|
||||
@@ -36,6 +43,13 @@ interface NutrientDailyBarChartProps {
|
||||
valueLabel: string;
|
||||
/** Optional dashed reference line value (EFSA target / ceiling). */
|
||||
referenceValue?: number | null;
|
||||
/**
|
||||
* v1.42 — each bar opens its day, through the doors every day-linked
|
||||
* chart has (click, the tooltip's button on touch, the dashed line, the
|
||||
* row of dots, the data table). A day logged as nothing has no bar to
|
||||
* open.
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
export function NutrientDailyBarChart({
|
||||
@@ -43,6 +57,7 @@ export function NutrientDailyBarChart({
|
||||
unit,
|
||||
valueLabel,
|
||||
referenceValue,
|
||||
dayLinks = false,
|
||||
}: NutrientDailyBarChartProps) {
|
||||
const fmt = useFormatters();
|
||||
const points = useMemo(
|
||||
@@ -60,71 +75,125 @@ export function NutrientDailyBarChart({
|
||||
const animate = !prefersReducedMotion();
|
||||
const barColor = "var(--info)";
|
||||
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: dayLinks,
|
||||
days: points.map((p) => (p.amount > 0 ? p.day : null)),
|
||||
focusFor: (index) => {
|
||||
const point = points[index];
|
||||
return point
|
||||
? { label: valueLabel, value: fmt.integer(point.amount), unit }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const logged = points.filter((p) => p.amount > 0);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
<>
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
/>
|
||||
{referenceValue != null ? (
|
||||
<ReferenceLine
|
||||
y={referenceValue}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { day: string; amount: number } }>
|
||||
| undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: valueLabel,
|
||||
value: `${point.amount} ${unit}`,
|
||||
color: barColor,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
/>
|
||||
{referenceValue != null ? (
|
||||
<ReferenceLine
|
||||
y={referenceValue}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
) : null}
|
||||
{chartDays.openIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
x={points[chartDays.openIndex]?.day}
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { day: string; amount: number } }>
|
||||
| undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: valueLabel,
|
||||
value: `${point.amount} ${unit}`,
|
||||
color: barColor,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill={barColor}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points.map((p) => ({ timestamp: dayAnchor(p.day) }))}
|
||||
axis="band"
|
||||
mark="bar"
|
||||
// The y axis (44) on the left, the margin (12) on the right.
|
||||
insetLeft={44}
|
||||
insetRight={12}
|
||||
/>
|
||||
{chartDays.active && logged.length > 0 ? (
|
||||
<ChartDataTable
|
||||
points={logged.map((p) => ({
|
||||
date: p.day,
|
||||
timestamp: dayAnchor(p.day),
|
||||
amount: p.amount,
|
||||
}))}
|
||||
columns={[{ key: "amount", label: valueLabel }]}
|
||||
unit={unit}
|
||||
formatValue={(value) => fmt.integer(value)}
|
||||
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
|
||||
bucket="day"
|
||||
metricLabel={valueLabel}
|
||||
dayLinks
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill={barColor}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,20 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import { ChartDataTable } from "../charts/chart-data-table";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
dayAnchor,
|
||||
useChartDayLinks,
|
||||
} from "../day/chart-day-links";
|
||||
import { dateKeyOfInstant } from "../day/day-url";
|
||||
import { formatMetricValue } from "./format-value";
|
||||
import type { CustomMetricEntryDto } from "./types";
|
||||
|
||||
@@ -48,11 +59,14 @@ interface ChartPoint {
|
||||
|
||||
export function CustomMetricChart({
|
||||
entries,
|
||||
name,
|
||||
unit,
|
||||
targetLow,
|
||||
targetHigh,
|
||||
decimals,
|
||||
}: {
|
||||
/** The metric's name: the day's focus label and the data table's caption. */
|
||||
name: string;
|
||||
/** Entries for this metric, any order (sorted internally). */
|
||||
entries: CustomMetricEntryDto[];
|
||||
unit: string;
|
||||
@@ -95,6 +109,25 @@ export function CustomMetricChart({
|
||||
return [min - pad, max + pad];
|
||||
}, [points, targetLow, targetHigh]);
|
||||
|
||||
// v1.42 — each value opens its day (the calendar day of its instant in
|
||||
// the record's zone) through the doors every day-linked chart has.
|
||||
const timeZone = useDisplayTimezone();
|
||||
const pointDays = points.map((point) =>
|
||||
dateKeyOfInstant(point.timestamp, timeZone),
|
||||
);
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: true,
|
||||
days: pointDays,
|
||||
focusFor: (index) => {
|
||||
const point = points[index];
|
||||
return point
|
||||
? { label: name, value: formatMetricValue(point.value, decimals), unit }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const openPoint =
|
||||
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
|
||||
@@ -121,94 +154,130 @@ export function CustomMetricChart({
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
|
||||
/>
|
||||
{targetLow != null && targetHigh != null ? (
|
||||
<ReferenceArea
|
||||
y1={targetLow}
|
||||
y2={targetHigh}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
) : null}
|
||||
{targetLow != null ? (
|
||||
<ReferenceLine
|
||||
y={targetLow}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{targetHigh != null ? (
|
||||
<ReferenceLine
|
||||
y={targetHigh}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) =>
|
||||
fmt.dateShortSmart(new Date(ts))
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("customMetrics.chart.valueLabel"),
|
||||
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
|
||||
/>
|
||||
{targetLow != null && targetHigh != null ? (
|
||||
<ReferenceArea
|
||||
y1={targetLow}
|
||||
y2={targetHigh}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
{targetLow != null ? (
|
||||
<ReferenceLine
|
||||
y={targetLow}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{targetHigh != null ? (
|
||||
<ReferenceLine
|
||||
y={targetHigh}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{openPoint ? (
|
||||
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("customMetrics.chart.valueLabel"),
|
||||
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points}
|
||||
axis="time"
|
||||
// The y axis (44) on the left, the margin (12) on the right.
|
||||
insetLeft={44}
|
||||
insetRight={12}
|
||||
/>
|
||||
<ChartDataTable
|
||||
points={points.map((point, index) => ({
|
||||
date: pointDays[index]!,
|
||||
timestamp: dayAnchor(pointDays[index]!),
|
||||
value: point.value,
|
||||
}))}
|
||||
columns={[{ key: "value", label: name }]}
|
||||
unit={unit}
|
||||
formatValue={(value) => formatMetricValue(value, decimals)}
|
||||
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
|
||||
bucket="day"
|
||||
metricLabel={name}
|
||||
dayLinks
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -287,6 +287,7 @@ export function CustomMetricDetail({
|
||||
<CardContent>
|
||||
<CustomMetricChart
|
||||
entries={entries}
|
||||
name={metric?.name ?? ""}
|
||||
unit={metric?.unit ?? ""}
|
||||
targetLow={metric?.targetLow ?? null}
|
||||
targetHigh={metric?.targetHigh ?? null}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* it surfaces a "log your morning temperature" hint rather than a flat dot.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Thermometer } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -46,6 +46,13 @@ import { cn } from "@/lib/utils";
|
||||
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
|
||||
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { ChartDataTable } from "@/components/charts/chart-data-table";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
|
||||
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
|
||||
const FALLBACK_WINDOW_DAYS = 35;
|
||||
@@ -190,6 +197,27 @@ export function BbtChart({
|
||||
|
||||
const hasCurve = points.length >= 2;
|
||||
|
||||
// v1.42 — each logged temperature opens its day through the doors every
|
||||
// day-linked chart has. Tapping a cycle-calendar cell still logs the day;
|
||||
// the chart is where a day is read.
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: hasCurve,
|
||||
days: points.map((p) => dateOnlyKey(new Date(p.t))),
|
||||
focusFor: (index) => {
|
||||
const p = points[index];
|
||||
return p
|
||||
? {
|
||||
label: t("cycle.bbt.title"),
|
||||
value: fmt.number(p.temp),
|
||||
unit: unitDisplay.unitFor("BODY_TEMPERATURE"),
|
||||
types: ["BODY_TEMPERATURE"],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const openPoint =
|
||||
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
|
||||
|
||||
return (
|
||||
<Card data-slot="cycle-bbt-chart">
|
||||
<CardHeader>
|
||||
@@ -213,14 +241,16 @@ export function BbtChart({
|
||||
{t("cycle.bbt.caption")}
|
||||
</p>
|
||||
<div
|
||||
className="touch-pan-y"
|
||||
className={cn("touch-pan-y", chartDays.plotClassName)}
|
||||
style={{ height: "210px" }}
|
||||
data-slot="cycle-bbt-area"
|
||||
{...chartDays.plotProps}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={points}
|
||||
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -248,13 +278,30 @@ export function BbtChart({
|
||||
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={<BbtTooltip />}
|
||||
content={(props) => {
|
||||
const payload = props.payload as unknown as
|
||||
{ payload: BbtPoint }[] | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
return (
|
||||
<BbtTooltip
|
||||
active={props.active}
|
||||
payload={payload}
|
||||
action={chartDays.tooltipAction(
|
||||
point ? points.indexOf(point) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{ovulationMs != null ? (
|
||||
<ReferenceLine
|
||||
@@ -290,6 +337,29 @@ export function BbtChart({
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points.map((p) => ({ timestamp: p.t }))}
|
||||
axis="time"
|
||||
// The margin (4) + the y axis (40) on the left, the margin (14)
|
||||
// on the right.
|
||||
insetLeft={4 + 40}
|
||||
insetRight={14}
|
||||
/>
|
||||
<ChartDataTable
|
||||
points={points.map((p) => ({
|
||||
date: dateOnlyKey(new Date(p.t)),
|
||||
timestamp: p.t,
|
||||
temp: p.temp,
|
||||
}))}
|
||||
columns={[{ key: "temp", label: t("cycle.bbt.title") }]}
|
||||
unit={unitDisplay.unitFor("BODY_TEMPERATURE")}
|
||||
formatValue={(value) => fmt.number(value)}
|
||||
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
|
||||
bucket="day"
|
||||
metricLabel={t("cycle.bbt.title")}
|
||||
dayLinks
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -317,6 +387,8 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
|
||||
export function BbtTooltip(props: {
|
||||
active?: boolean;
|
||||
payload?: { payload: BbtPoint }[];
|
||||
/** v1.42 — the tooltip's way to the day on a touch screen. */
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -350,6 +422,7 @@ export function BbtTooltip(props: {
|
||||
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{props.action ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,271 @@
|
||||
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 (a click opens the day, on touch
|
||||
* the tooltip's "View the whole day", 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 adherence chart, 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 its touch 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",
|
||||
"MedicationComplianceChart",
|
||||
"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/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 touch 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; on the dashboard its seven charts, the mood line
|
||||
// and the adherence chart.
|
||||
expect(doors.mounts).toBeGreaterThanOrEqual(20);
|
||||
// HealthChart, the mood line and the nutrient bars themselves, sleep
|
||||
// stages, mood dimensions, lab marker, custom metric, questionnaire,
|
||||
// basal temperature, dose strength, efficacy, the adherence chart:
|
||||
// twelve.
|
||||
expect(
|
||||
doors.chartFiles.length,
|
||||
doors.chartFiles.join("\n"),
|
||||
).toBeGreaterThanOrEqual(12);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest";
|
||||
* Where a date must NOT open the day (integration plan §1.2), frozen.
|
||||
*
|
||||
* - the dashboard's today area: the dashboard is today; past days open as
|
||||
* a layer over it, never as date navigation inside it;
|
||||
* - a tile's mini chart: too small a target, and the tile has its own
|
||||
* destination (the chart side is pinned in `health-chart-day-links`);
|
||||
* a layer over it, never as date navigation inside it. The dashboard's
|
||||
* charts below it do open their days (the maintainer's call on the
|
||||
* beta), like every chart drawn in days (`day-door-coverage-guard`);
|
||||
* - a trend card: it shows the latest value and a direction, no day, and
|
||||
* the whole tile leads to its metric page (the mini-chart side is
|
||||
* pinned in `health-chart-day-links`);
|
||||
* - the cycle calendar: a tap there has meant "log this day" for years;
|
||||
* - model-written text (the briefing, the Coach's prose): never parsed for
|
||||
* dates; the Coach's days come from its tool calls as chips;
|
||||
@@ -64,11 +67,14 @@ describe("the day view stays out of the surfaces it must not reach", () => {
|
||||
expect(offenders.map((f) => relative(ROOT, f))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the dashboard's charts never ask for day links", () => {
|
||||
it("the dashboard's today area hands no day door to its hero", () => {
|
||||
// The charts below the hero open their days; the hero itself is today
|
||||
// and stays on the list above. Pinned from the other side: the hero is
|
||||
// still mounted by the dashboard, so the list entry is not stale.
|
||||
const dashboard = readFileSync(
|
||||
join(ROOT, "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(dashboard).not.toMatch(/\bdayLinks\b/);
|
||||
expect(dashboard).toMatch(/<TodayHero\b/);
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,7 @@ let canWrite = true;
|
||||
|
||||
vi.mock("../use-day", () => ({
|
||||
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
|
||||
usePrefetchDay: () => () => undefined,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* A day's values read as their charts read them. The day route sends a
|
||||
* night's length in "min" and a device score in "score"; the first read as a
|
||||
* bare 412 and the second as an untranslated "score" before both were
|
||||
* spelled the way the sleep and recovery charts spell them.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-unit-display", async () => {
|
||||
const { unitDisplayFor } =
|
||||
await import("@/__tests__/helpers/unit-display-mock");
|
||||
return { useUnitDisplay: () => unitDisplayFor("metric") };
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { useDayValueFormat } from "../use-day-value-format";
|
||||
|
||||
function Probe({
|
||||
type,
|
||||
value,
|
||||
unit,
|
||||
}: {
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}) {
|
||||
const { formatTile } = useDayValueFormat();
|
||||
const shown = formatTile({
|
||||
key: type,
|
||||
values: [{ type, value, unit, at: "", source: "", band: null }],
|
||||
});
|
||||
return (
|
||||
<span>
|
||||
{shown.value}|{shown.unit}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function read(
|
||||
type: string,
|
||||
value: number,
|
||||
unit: string,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider
|
||||
initialLocale={locale}
|
||||
initialMessages={locale === "de" ? deMessages : enMessages}
|
||||
>
|
||||
<Probe type={type} value={value} unit={unit} />
|
||||
</I18nProvider>,
|
||||
)
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/<!-- -->/g, "");
|
||||
}
|
||||
|
||||
describe("a day's values in their charts' spelling", () => {
|
||||
it("reads a night sent in min as a duration", () => {
|
||||
const [value, unit] = read("SLEEP_DURATION", 412, "min").split("|");
|
||||
expect(value).toMatch(/6\s*h/);
|
||||
expect(value).toMatch(/52/);
|
||||
expect(unit).toBe("");
|
||||
});
|
||||
|
||||
it("reads a device score in the reader's word for points", () => {
|
||||
// German, where the wire's "score" and the chart's "Punkte" differ.
|
||||
const [value, unit] = read("ANS_CHARGE", 8, "score", "de").split("|");
|
||||
expect(value).toBe("8");
|
||||
expect(unit).toBe("Punkte");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
|
||||
import {
|
||||
ChartDayCaption,
|
||||
DayRug,
|
||||
TooltipDayAction,
|
||||
useCoarsePointer,
|
||||
type ChartDayAxis,
|
||||
type ChartDayMark,
|
||||
} from "./chart-day";
|
||||
import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller";
|
||||
import { isOpenableDay } from "./day-url";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
* The day view's doors on any Recharts chart drawn in days, in one place.
|
||||
*
|
||||
* `HealthChart` was the first chart to open its days; every other chart with
|
||||
* a date axis (the sleep stages, a lab marker, the mood line, a bar per day)
|
||||
* takes the same doors through this hook, so a click, a tap, the tooltip's
|
||||
* button, the dashed line through the open day and the row of dots under the
|
||||
* axis behave the same on every page:
|
||||
*
|
||||
* - fine pointer: a click on the plot opens the day of the point under the
|
||||
* cursor; hovering shows the tooltip as before;
|
||||
* - touch: a tap shows the value and pins the tooltip, whose button
|
||||
* ("View the whole day", a calm 44 px row) opens the day. Two steps on
|
||||
* purpose (the maintainer's call, after trying one on the beta): a
|
||||
* point is a small target, and one tap that opened a sheet would open
|
||||
* days by accident;
|
||||
* - the open day is marked with `OPEN_DAY_LINE` (a dashed vertical line)
|
||||
* at `openIndex`;
|
||||
* - `ChartDayFooter` draws the row of day dots and the one-line caption.
|
||||
*
|
||||
* The caller says, per drawn point and in data order, which calendar day the
|
||||
* point stands for, or `null` when it stands for none (a week or month
|
||||
* average, a synthetic "now" point, a day with nothing on it). A day in the
|
||||
* future never opens.
|
||||
*/
|
||||
|
||||
/** The props of the dashed line through the open day, for `<ReferenceLine>`. */
|
||||
export const OPEN_DAY_LINE = {
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.7,
|
||||
ifOverflow: "discard",
|
||||
} as const;
|
||||
|
||||
/** What a chart's click handler receives from Recharts. */
|
||||
interface ChartClickState {
|
||||
activeTooltipIndex?: unknown;
|
||||
activeLabel?: unknown;
|
||||
}
|
||||
|
||||
export interface ChartDayLinksOptions {
|
||||
/** Off for a mini chart, a coarse bucket, or a surface on the NOT-here list. */
|
||||
enabled: boolean;
|
||||
/** Per drawn point, in data order: its calendar day, or null. */
|
||||
days: ReadonlyArray<DateKey | null>;
|
||||
/** What the day shows at its top when opened from point `index`. */
|
||||
focusFor?: (index: number) => Omit<DayFocus, "date"> | null;
|
||||
/**
|
||||
* For a chart whose plotted series are not the chart's `data` (several
|
||||
* `<Line data>` arrays on one time axis): the point index of the click's
|
||||
* x value. Defaults to Recharts' `activeTooltipIndex`.
|
||||
*/
|
||||
indexOfClick?: (state: ChartClickState) => number | null;
|
||||
}
|
||||
|
||||
export interface ChartDayLinks {
|
||||
active: boolean;
|
||||
coarse: boolean;
|
||||
/** Index of the open day's point, for the dashed line. */
|
||||
openIndex: number | undefined;
|
||||
/** The open day itself, for charts that mark it by value. */
|
||||
openDay: DateKey | null;
|
||||
/** Open the day of point `index` (no-op without a day). */
|
||||
open: (index: number) => void;
|
||||
/** True when point `index` has a day that opens. */
|
||||
canOpen: (index: number) => boolean;
|
||||
/** Spread on the element around `<ResponsiveContainer>`. */
|
||||
plotProps: {
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
"data-day-links"?: "true";
|
||||
};
|
||||
/** Class for the same element: a pointer cursor on a fine pointer. */
|
||||
plotClassName: string;
|
||||
/** Pass as the Recharts chart's `onClick`. */
|
||||
onChartClick:
|
||||
((state: ChartClickState | null | undefined) => void) | undefined;
|
||||
/**
|
||||
* Spread on `<Tooltip>`: on touch it stays where the tap put it and takes
|
||||
* taps. It does not glide there either: Recharts slides the tooltip from
|
||||
* where it last was for 400 ms, which turned "View the whole day" into a
|
||||
* moving target under the finger. Reduced motion stops the glide on every
|
||||
* pointer.
|
||||
*/
|
||||
tooltipProps: {
|
||||
trigger: "click" | "hover";
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
isAnimationActive?: boolean;
|
||||
};
|
||||
/** The tooltip's button for point `index`, on a touch screen only. */
|
||||
tooltipAction: (index: number | undefined) => ReactNode | undefined;
|
||||
}
|
||||
|
||||
export function useChartDayLinks({
|
||||
enabled,
|
||||
days,
|
||||
focusFor,
|
||||
indexOfClick,
|
||||
}: ChartDayLinksOptions): ChartDayLinks {
|
||||
const coarse = useCoarsePointer();
|
||||
const openKey = useOpenDay();
|
||||
const today = useTodayKey();
|
||||
const plotRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const canOpen = useCallback(
|
||||
(index: number) => enabled && isOpenableDay(days[index] ?? null, today),
|
||||
[enabled, days, today],
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
(index: number) => {
|
||||
if (!canOpen(index)) return;
|
||||
const date = days[index] as DateKey;
|
||||
const focus = focusFor?.(index) ?? null;
|
||||
openDay(date, {
|
||||
focus: focus ? { ...focus, date } : null,
|
||||
trigger: plotRef.current,
|
||||
});
|
||||
},
|
||||
[canOpen, days, focusFor],
|
||||
);
|
||||
|
||||
const openIndex = useMemo(() => {
|
||||
if (!enabled || openKey === null) return undefined;
|
||||
const at = days.lastIndexOf(openKey);
|
||||
return at === -1 ? undefined : at;
|
||||
}, [enabled, openKey, days]);
|
||||
|
||||
const onChartClick =
|
||||
enabled && !coarse
|
||||
? (state: ChartClickState | null | undefined) => {
|
||||
if (!state) return;
|
||||
const index = indexOfClick
|
||||
? indexOfClick(state)
|
||||
: Number(state.activeTooltipIndex);
|
||||
if (index === null || !Number.isInteger(index)) return;
|
||||
open(index);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
active: enabled,
|
||||
coarse,
|
||||
openIndex,
|
||||
openDay: enabled ? openKey : null,
|
||||
open,
|
||||
canOpen,
|
||||
plotProps: {
|
||||
ref: plotRef,
|
||||
"data-day-links": enabled ? "true" : undefined,
|
||||
},
|
||||
plotClassName: enabled && !coarse ? "cursor-pointer" : "",
|
||||
onChartClick,
|
||||
tooltipProps:
|
||||
enabled && coarse
|
||||
? {
|
||||
trigger: "click",
|
||||
wrapperStyle: { pointerEvents: "auto", zIndex: 20 },
|
||||
isAnimationActive: false,
|
||||
}
|
||||
: prefersReducedMotion()
|
||||
? { trigger: "hover", isAnimationActive: false }
|
||||
: { trigger: "hover" },
|
||||
tooltipAction: (index) =>
|
||||
enabled && coarse && index !== undefined && canOpen(index) ? (
|
||||
<TooltipDayAction onOpen={() => open(index)} />
|
||||
) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of day dots and the caption under a day-linked chart. Nothing when
|
||||
* the chart is not day-linked or draws no points.
|
||||
*/
|
||||
export function ChartDayFooter({
|
||||
links,
|
||||
points,
|
||||
axis = "index",
|
||||
insetLeft,
|
||||
insetRight,
|
||||
mark = "point",
|
||||
}: {
|
||||
links: Pick<ChartDayLinks, "active" | "coarse">;
|
||||
/** The drawn points' noon-UTC day anchors (or instants), in data order. */
|
||||
points: ReadonlyArray<{ timestamp: number }>;
|
||||
axis?: ChartDayAxis;
|
||||
insetLeft: number;
|
||||
insetRight: number;
|
||||
mark?: ChartDayMark;
|
||||
}) {
|
||||
if (!links.active || points.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<DayRug
|
||||
points={points}
|
||||
axis={axis}
|
||||
insetLeft={insetLeft}
|
||||
insetRight={insetRight}
|
||||
/>
|
||||
<ChartDayCaption coarse={links.coarse} mark={mark} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A day key's noon-UTC anchor, the timestamp a daily point is drawn at. */
|
||||
export function dayAnchor(day: DateKey): number {
|
||||
return Date.parse(`${day}T12:00:00.000Z`);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -19,8 +19,9 @@ import { useTodayKey } from "./use-today-key";
|
||||
* - On a fine pointer, a click on the plot opens the day of the point under
|
||||
* the cursor; hovering keeps showing the tooltip as before.
|
||||
* - On a touch screen a tap shows the value, as it always did, and the
|
||||
* tooltip carries "View the whole day". Two steps on purpose: one tap
|
||||
* that opened a sheet would fire on every scroll that grazed the chart.
|
||||
* tooltip carries "View the whole day". Two steps on purpose: a point is
|
||||
* a small target, and one tap that opened a sheet would open days by
|
||||
* accident. A date in a list is a bigger target and opens in one tap.
|
||||
* - Under the axis, a quiet row of dots marks the days that hold anything,
|
||||
* a ring the days with a notable observation. Decoration for the eye; the
|
||||
* keyboard and screen-reader way to a day is the chart's data table.
|
||||
@@ -59,19 +60,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();
|
||||
@@ -119,19 +107,62 @@ export function rugPosition(
|
||||
return points.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a chart spreads its points along the x axis, which decides where a
|
||||
* day's dot sits under it:
|
||||
*
|
||||
* - `index`: the axis is the point index, the first point at the left edge
|
||||
* of the plot and the last at the right (`HealthChart`, the mood line, a
|
||||
* category line chart);
|
||||
* - `band`: one band per point, the point at the band's centre (a bar
|
||||
* chart);
|
||||
* - `time`: the axis is the timestamp itself, from the first point to the
|
||||
* last (a lab chart, any `scale="time"` axis).
|
||||
*/
|
||||
export type ChartDayAxis = "index" | "band" | "time";
|
||||
|
||||
/**
|
||||
* Where along the plot (0 at the left edge, 1 at the right) a day's dot
|
||||
* belongs, or null outside the drawn span.
|
||||
*/
|
||||
export function rugFraction(
|
||||
day: DateKey,
|
||||
points: readonly RugPoint[],
|
||||
axis: ChartDayAxis,
|
||||
): number | null {
|
||||
const at = rugPosition(day, points);
|
||||
if (at === null) return null;
|
||||
const n = points.length;
|
||||
if (axis === "band") return (at + 0.5) / n;
|
||||
if (n === 1) return 0.5;
|
||||
if (axis === "time") {
|
||||
const first = points[0]!.timestamp;
|
||||
const last = points[n - 1]!.timestamp;
|
||||
const t = Date.parse(`${day}T12:00:00.000Z`);
|
||||
return last === first ? 0.5 : (t - first) / (last - first);
|
||||
}
|
||||
return at / (n - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of day dots under a daily chart. `insetLeft` / `insetRight` are the
|
||||
* pixels between the chart box and the first and last point (margin, axis,
|
||||
* padding), so a dot lands under its day.
|
||||
*
|
||||
* The row is always drawn at its full height, dots or not: the index that
|
||||
* fills it arrives after the chart, and a row that appeared then would push
|
||||
* everything under the chart down by its height.
|
||||
*/
|
||||
export function DayRug({
|
||||
points,
|
||||
insetLeft,
|
||||
insetRight,
|
||||
axis = "index",
|
||||
}: {
|
||||
points: readonly RugPoint[];
|
||||
insetLeft: number;
|
||||
insetRight: number;
|
||||
axis?: ChartDayAxis;
|
||||
}) {
|
||||
const today = useTodayKey();
|
||||
const open = useOpenDay();
|
||||
@@ -141,18 +172,16 @@ export function DayRug({
|
||||
const to = last ? chartPointDayKey(last.timestamp) : null;
|
||||
const index = useDayIndex(from, to);
|
||||
const data = index.data;
|
||||
if (!data?.days || points.length === 0) return null;
|
||||
const notable = new Set(data.notable ?? []);
|
||||
const maxIndex = Math.max(points.length - 1, 1);
|
||||
const days = Object.keys(data.days)
|
||||
const notable = new Set(data?.notable ?? []);
|
||||
const days = Object.keys(data?.days ?? {})
|
||||
.filter((day) => isOpenableDay(day, today))
|
||||
.map((day) => ({ day, at: rugPosition(day, points) }))
|
||||
.map((day) => ({ day, at: rugFraction(day, points, axis) }))
|
||||
.filter((entry): entry is { day: string; at: number } => entry.at !== null);
|
||||
if (days.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="day-rug"
|
||||
data-axis={axis}
|
||||
className="pointer-events-none relative h-3"
|
||||
style={{ marginLeft: insetLeft, marginRight: insetRight }}
|
||||
>
|
||||
@@ -173,9 +202,7 @@ export function DayRug({
|
||||
? "border-muted-foreground size-[7px] border-[1.5px]"
|
||||
: "bg-muted-foreground/55 size-1",
|
||||
)}
|
||||
style={{
|
||||
left: `${(points.length === 1 ? 0.5 : at / maxIndex) * 100}%`,
|
||||
}}
|
||||
style={{ left: `${at * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -183,16 +210,40 @@ export function DayRug({
|
||||
);
|
||||
}
|
||||
|
||||
/** The one line under a day-linked chart: what the dots mean, and the click. */
|
||||
export function ChartDayCaption({ coarse }: { coarse: boolean }) {
|
||||
/**
|
||||
* What a day-linked surface is made of, which names the thing to click in
|
||||
* the caption: a point on a line, a bar, or a cell of a calendar.
|
||||
*/
|
||||
export type ChartDayMark = "point" | "bar" | "cell";
|
||||
|
||||
const HINT_KEYS: Record<ChartDayMark, { fine: string; coarse: string }> = {
|
||||
point: { fine: "day.chartHint", coarse: "day.chartHintTouch" },
|
||||
bar: { fine: "day.chartHintBar", coarse: "day.chartHintTouchBar" },
|
||||
cell: { fine: "day.chartHintCell", coarse: "day.chartHintTouchCell" },
|
||||
};
|
||||
|
||||
/**
|
||||
* The one line under a day-linked chart: what the dots mean (when the chart
|
||||
* has the row of dots), and the click.
|
||||
*/
|
||||
export function ChartDayCaption({
|
||||
coarse,
|
||||
mark = "point",
|
||||
rug = true,
|
||||
}: {
|
||||
coarse: boolean;
|
||||
mark?: ChartDayMark;
|
||||
rug?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hint = HINT_KEYS[mark];
|
||||
return (
|
||||
<p
|
||||
data-slot="chart-day-caption"
|
||||
className="text-muted-foreground mt-1 flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs"
|
||||
>
|
||||
<span>{t("day.rugLegend")}</span>
|
||||
<span>{coarse ? t("day.chartHintTouch") : t("day.chartHint")}</span>
|
||||
{rug ? <span>{t("day.rugLegend")}</span> : null}
|
||||
<span>{coarse ? t(hint.coarse) : t(hint.fine)}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -51,9 +51,16 @@ interface LayerMemory {
|
||||
trigger: HTMLElement | null;
|
||||
/** The day the layer shows, as the mounted layer resolved it. */
|
||||
open: DateKey | null;
|
||||
/** The trigger's top edge on screen when it opened the layer. */
|
||||
triggerTop: number | null;
|
||||
}
|
||||
|
||||
const memory: LayerMemory = { focus: null, trigger: null, open: null };
|
||||
const memory: LayerMemory = {
|
||||
focus: null,
|
||||
trigger: null,
|
||||
open: null,
|
||||
triggerTop: null,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
@@ -95,6 +102,17 @@ export function publishOpenDay(date: DateKey | null): void {
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that opened the layer and where it stood on screen at that
|
||||
* moment, without handing it over: the docked column keeps it there while
|
||||
* the page beside it reflows.
|
||||
*/
|
||||
export function peekDayTriggerAt(): { el: HTMLElement; top: number } | null {
|
||||
return memory.trigger && memory.triggerTop !== null
|
||||
? { el: memory.trigger, top: memory.triggerTop }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The element focus returns to when the layer closes. */
|
||||
export function takeDayTrigger(): HTMLElement | null {
|
||||
const trigger = memory.trigger;
|
||||
@@ -124,6 +142,9 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
|
||||
? active
|
||||
: null;
|
||||
memory.trigger = options.trigger ?? focused ?? memory.trigger;
|
||||
memory.triggerTop = memory.trigger
|
||||
? memory.trigger.getBoundingClientRect().top
|
||||
: null;
|
||||
emit();
|
||||
const { pathname, search } = currentLocation();
|
||||
const href = withDayHref(pathname, search, date);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
@@ -26,13 +27,15 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
closeDay,
|
||||
peekDayTriggerAt,
|
||||
publishOpenDay,
|
||||
stepDay,
|
||||
takeDayTrigger,
|
||||
useDayFocus,
|
||||
} from "./day-layer-controller";
|
||||
import { parseDayParam, withDayHref } from "./day-url";
|
||||
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
|
||||
import { DayView, useLongDayLabel } from "./day-view";
|
||||
import { usePrefetchDay } from "./use-day";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
@@ -125,6 +128,12 @@ function DayLayer() {
|
||||
? "docked"
|
||||
: "sheet";
|
||||
|
||||
// The docked column narrows the page beside it, and the page reflows. Keep
|
||||
// what opened the day where it was on screen, through the open and the
|
||||
// close, so the chart under the pointer does not jump away from it.
|
||||
useKeepAnchorInView(date !== null && shell === "docked");
|
||||
|
||||
const prefetchDay = usePrefetchDay();
|
||||
const focus = useDayFocus(date);
|
||||
const longLabel = useLongDayLabel();
|
||||
const titleId = useId();
|
||||
@@ -163,6 +172,13 @@ function DayLayer() {
|
||||
if (date === null) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
// Alt is the first half of the step shortcut: read both neighbours
|
||||
// while the arrow is still on its way, so the step paints a day.
|
||||
if (event.key === "Alt") {
|
||||
prefetchDay(shiftDateKey(date, -1));
|
||||
if (date < today) prefetchDay(shiftDateKey(date, 1));
|
||||
return;
|
||||
}
|
||||
const inside =
|
||||
panelRef.current?.contains(event.target as Node | null) ?? false;
|
||||
if (event.key === "Escape" && shell === "docked") {
|
||||
@@ -184,7 +200,7 @@ function DayLayer() {
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [date, shell, onStep]);
|
||||
}, [date, shell, onStep, prefetchDay, today]);
|
||||
|
||||
const live = (
|
||||
<p
|
||||
@@ -227,7 +243,10 @@ function DayLayer() {
|
||||
aria-labelledby={titleId}
|
||||
data-slot="day-panel"
|
||||
data-shell="docked"
|
||||
className="bg-card text-card-foreground border-border flex h-full w-105 shrink-0 flex-col border-l"
|
||||
// A short fade, not a slide: the column takes its width at once
|
||||
// (the page beside it reflows in the same frame either way), so
|
||||
// only its content eases in. None under reduced motion.
|
||||
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
|
||||
>
|
||||
{view(DockedTitle, {
|
||||
headerClassName: cn(
|
||||
@@ -254,6 +273,55 @@ function DayLayer() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the element that opened the day at the same place on screen while the
|
||||
* page beside the docked column reflows (the column taking or giving back its
|
||||
* width). Chromium does this itself (CSS scroll anchoring); Safari does not,
|
||||
* and there the chart the person clicked slid away under the pointer by the
|
||||
* height the reflow added above it. Measured after layout, so where the
|
||||
* browser already anchored, the correction is zero and nothing moves twice.
|
||||
*/
|
||||
function useKeepAnchorInView(active: boolean) {
|
||||
const anchor = useRef<{ el: HTMLElement; top: number } | null>(null);
|
||||
const wasActive = useRef(false);
|
||||
|
||||
// Before paint, so the corrected position is the first one drawn.
|
||||
useLayoutEffect(() => {
|
||||
if (active === wasActive.current) return;
|
||||
wasActive.current = active;
|
||||
const main = document.getElementById("main-content");
|
||||
if (!main) return;
|
||||
if (active) {
|
||||
// Where the opener was when it was clicked, before the column came.
|
||||
const opened = peekDayTriggerAt();
|
||||
anchor.current =
|
||||
opened && main.contains(opened.el)
|
||||
? { el: opened.el, top: opened.top }
|
||||
: null;
|
||||
}
|
||||
const a = anchor.current;
|
||||
if (a && a.el.isConnected) {
|
||||
const delta = a.el.getBoundingClientRect().top - a.top;
|
||||
if (Math.abs(delta) > 1) main.scrollTop += delta;
|
||||
a.top = a.el.getBoundingClientRect().top;
|
||||
}
|
||||
if (!active) anchor.current = null;
|
||||
}, [active]);
|
||||
|
||||
// While the column is open, the page may scroll; the anchor follows.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const main = document.getElementById("main-content");
|
||||
if (!main) return;
|
||||
const record = () => {
|
||||
const a = anchor.current;
|
||||
if (a && a.el.isConnected) a.top = a.el.getBoundingClientRect().top;
|
||||
};
|
||||
main.addEventListener("scroll", record, { passive: true });
|
||||
return () => main.removeEventListener("scroll", record);
|
||||
}, [active]);
|
||||
}
|
||||
|
||||
function DockedTitle({
|
||||
ref,
|
||||
...props
|
||||
@@ -362,7 +430,9 @@ function DaySheet({
|
||||
returnFocus();
|
||||
}}
|
||||
className={cn(
|
||||
"bg-card gap-0 p-0",
|
||||
// Quicker than the primitive's 500 ms, which read as lag on a
|
||||
// layer opened from a tap; no slide at all under reduced motion.
|
||||
"bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none",
|
||||
bottom
|
||||
? cn(
|
||||
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
ChevronRight,
|
||||
History,
|
||||
MessageCircle,
|
||||
PanelBottomClose,
|
||||
PanelRightClose,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -38,7 +39,8 @@ import {
|
||||
DayValues,
|
||||
} from "./day-sections";
|
||||
import { curateDayValues, tileKeyOf } from "./day-values-model";
|
||||
import { useDay } from "./use-day";
|
||||
import { shiftDateKey } from "./day-url";
|
||||
import { useDay, usePrefetchDay } from "./use-day";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
|
||||
/**
|
||||
@@ -52,14 +54,16 @@ import { useDayValueFormat } from "./use-day-value-format";
|
||||
*/
|
||||
|
||||
/** "Saturday, 3 January 2026", for the header and the spoken name. */
|
||||
export function useLongDayLabel(): (date: DateKey) => string {
|
||||
export function useLongDayLabel(
|
||||
length: "long" | "short" = "long",
|
||||
): (date: DateKey) => string {
|
||||
const { locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (date: DateKey) =>
|
||||
new Intl.DateTimeFormat(intl, {
|
||||
weekday: "long",
|
||||
weekday: length,
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: length,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T12:00:00.000Z`));
|
||||
@@ -91,6 +95,16 @@ export interface DayViewProps {
|
||||
const ICON_BUTTON =
|
||||
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
|
||||
|
||||
/**
|
||||
* The close control is the Coach panel's own toggle, in the same place: the
|
||||
* panel's top-left corner, the panel icon mirrored so it points the way the
|
||||
* panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as a
|
||||
* copy so the day layer does not pull the Coach's panel into the shell).
|
||||
*/
|
||||
const PANEL_TOGGLE =
|
||||
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
|
||||
const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
|
||||
|
||||
export function DayView({
|
||||
date,
|
||||
today,
|
||||
@@ -106,6 +120,10 @@ export function DayView({
|
||||
}: DayViewProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const longLabel = useLongDayLabel();
|
||||
// "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday
|
||||
// spelled out in full does not fit beside the arrows at 360 px, and a
|
||||
// date cut off before its year reads as the wrong day.
|
||||
const shortLabel = useLongDayLabel("short");
|
||||
const pathname = usePathname();
|
||||
const day = useDay(date);
|
||||
const { labelFor, formatTile } = useDayValueFormat();
|
||||
@@ -114,6 +132,15 @@ export function DayView({
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { user } = useAuth();
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
// The neighbouring day is read while the pointer rests on its arrow (or
|
||||
// the keyboard reaches it, or a finger lands on it), so the step paints a
|
||||
// day, not a skeleton.
|
||||
const prefetchDay = usePrefetchDay();
|
||||
const warm = (delta: number) => () => {
|
||||
const next = shiftDateKey(date, delta);
|
||||
if (delta > 0 && next > today) return;
|
||||
prefetchDay(next);
|
||||
};
|
||||
|
||||
const canCapture =
|
||||
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
|
||||
@@ -163,28 +190,49 @@ export function DayView({
|
||||
data-slot="day-header"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
compact
|
||||
? "pt-1.5 pr-2 pb-2.5 pl-4"
|
||||
: "border-border border-b pr-3 pl-6",
|
||||
compact ? "pt-0.5 pr-2 pb-2 pl-2" : "border-border border-b px-3",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Title
|
||||
id={titleId}
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="truncate text-base leading-snug font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{longLabel(date)}
|
||||
</Title>
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs tabular-nums"
|
||||
data-slot="day-meta"
|
||||
>
|
||||
{meta ?? <span className="invisible">·</span>}
|
||||
</p>
|
||||
</div>
|
||||
{/* Top left, where the Coach panel keeps its toggle: the way out. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-close"
|
||||
aria-label={t("day.close")}
|
||||
title={t("day.close")}
|
||||
onClick={onClose}
|
||||
className={PANEL_TOGGLE}
|
||||
>
|
||||
{compact ? (
|
||||
<PanelBottomClose
|
||||
className={PANEL_TOGGLE_ICON}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<PanelRightClose
|
||||
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{/* One line, centred in the band: the date and nothing under it. */}
|
||||
<Title
|
||||
id={titleId}
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{compact ? (
|
||||
<>
|
||||
<span aria-hidden="true">{shortLabel(date)}</span>
|
||||
<span className="sr-only">{longLabel(date)}</span>
|
||||
</>
|
||||
) : (
|
||||
longLabel(date)
|
||||
)}
|
||||
</Title>
|
||||
<Button
|
||||
type="button"
|
||||
variant={compact ? "ghost" : "outline"}
|
||||
@@ -192,6 +240,9 @@ export function DayView({
|
||||
data-slot="day-prev"
|
||||
aria-label={t("day.previousDay")}
|
||||
title={t("day.previousDayShortcut")}
|
||||
onPointerEnter={warm(-1)}
|
||||
onPointerDown={warm(-1)}
|
||||
onFocus={warm(-1)}
|
||||
onClick={() => onStep(-1)}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
@@ -208,6 +259,9 @@ export function DayView({
|
||||
aria-label={t("day.nextDay")}
|
||||
title={t("day.nextDayShortcut")}
|
||||
disabled={date >= today}
|
||||
onPointerEnter={warm(1)}
|
||||
onPointerDown={warm(1)}
|
||||
onFocus={warm(1)}
|
||||
onClick={() => onStep(1)}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
@@ -216,18 +270,6 @@ export function DayView({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-close"
|
||||
aria-label={t("day.close")}
|
||||
title={t("day.close")}
|
||||
onClick={onClose}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -237,6 +279,14 @@ export function DayView({
|
||||
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-5 pb-6",
|
||||
)}
|
||||
>
|
||||
{/* What the day holds, as meta under the header. The line keeps its
|
||||
height while the day loads, so nothing below it moves. */}
|
||||
<p
|
||||
className="text-muted-foreground -mb-3 text-xs tabular-nums"
|
||||
data-slot="day-meta"
|
||||
>
|
||||
{meta ?? <span className="invisible">·</span>}
|
||||
</p>
|
||||
{focus ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayFocusCard
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
|
||||
import { TooltipDayAction, useCoarsePointer } from "./chart-day";
|
||||
import { openDay, useOpenDay } from "./day-layer-controller";
|
||||
import { isOpenableDay } from "./day-url";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
* The day view's doors on a calendar heatmap (mood, intake), the cell twin of
|
||||
* `useChartDayLinks`:
|
||||
*
|
||||
* - fine pointer: hovering a cell shows its tooltip, a click opens its day;
|
||||
* - touch: a tap pins the tooltip, and the tooltip's button ("View the
|
||||
* whole day", the same calm 44 px row every chart's tooltip carries)
|
||||
* opens it. Two steps, as on a chart: a cell is a small target. A tap
|
||||
* outside or Escape clears the tooltip;
|
||||
* - the open day's cell carries a dashed outline, the heatmap's form of
|
||||
* the dashed line a chart draws through it (`OPEN_DAY_CELL`);
|
||||
* - the keyboard and screen-reader way to a day is the heatmap's data
|
||||
* table (`ChartDataTable` with `dayLinks`), as for every chart.
|
||||
*
|
||||
* A cell opens only when its day holds an entry and is not in the future.
|
||||
*/
|
||||
|
||||
/** The dashed outline drawn over the open day's cell. */
|
||||
export const OPEN_DAY_CELL = {
|
||||
fill: "none",
|
||||
stroke: "var(--foreground)",
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: "3 2",
|
||||
} as const;
|
||||
|
||||
interface HeatmapTooltip {
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
/** A touch tooltip stays after the finger lifts. */
|
||||
pinned?: boolean;
|
||||
/** The day a pinned tooltip offers to open. */
|
||||
day?: DateKey;
|
||||
}
|
||||
|
||||
export interface HeatmapCellInput {
|
||||
dateKey: string;
|
||||
/** True when the day holds an entry this heatmap shows. */
|
||||
hasEntry: boolean;
|
||||
/** The cell's text, for the tooltip. */
|
||||
describe: () => string;
|
||||
}
|
||||
|
||||
export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
|
||||
const [tooltip, setTooltip] = useState<HeatmapTooltip | null>(null);
|
||||
// The pointer type of the last press decides whether the click that
|
||||
// follows opens the day (mouse, pen) or only pinned the tooltip (touch).
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
const openKey = useOpenDay();
|
||||
const coarse = useCoarsePointer();
|
||||
|
||||
// A tap outside the heatmap clears a pinned tooltip.
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) setTooltip(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
}, [tooltip?.pinned, containerRef]);
|
||||
|
||||
// Escape dismisses any open tooltip.
|
||||
useEffect(() => {
|
||||
if (!tooltip) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setTooltip(null);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [tooltip]);
|
||||
|
||||
const canOpen = (cell: HeatmapCellInput): boolean =>
|
||||
cell.hasEntry && isOpenableDay(cell.dateKey, today);
|
||||
|
||||
const open = (day: DateKey) => {
|
||||
setTooltip(null);
|
||||
openDay(day, { trigger: containerRef.current as HTMLElement | null });
|
||||
};
|
||||
|
||||
/** Spread on a cell's `<rect>`. */
|
||||
const cellProps = (cell: HeatmapCellInput) => {
|
||||
const openable = canOpen(cell);
|
||||
return {
|
||||
"data-day": cell.dateKey,
|
||||
"data-open": openable && openKey === cell.dateKey ? "true" : undefined,
|
||||
className: openable ? "cursor-pointer" : undefined,
|
||||
onPointerEnter: (e: React.PointerEvent) => {
|
||||
// A touch enter fires just before `pointerdown`; the pinned
|
||||
// tooltip below takes over with its real coordinates.
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({ x: e.clientX, y: e.clientY, text: cell.describe() });
|
||||
},
|
||||
onPointerLeave: (e: React.PointerEvent) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
},
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cell.describe(),
|
||||
pinned: true,
|
||||
day: openable ? cell.dateKey : undefined,
|
||||
});
|
||||
},
|
||||
onClick: () => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (!openable) return;
|
||||
open(cell.dateKey);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Spread on the heatmap's `<svg>`. */
|
||||
const svgProps = {
|
||||
onMouseLeave: () => setTooltip((prev) => (prev?.pinned ? prev : null)),
|
||||
};
|
||||
|
||||
const tooltipNode = tooltip ? (
|
||||
<div
|
||||
data-slot="heatmap-tooltip"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
left:
|
||||
typeof window !== "undefined"
|
||||
? Math.max(8, Math.min(tooltip.x + 10, window.innerWidth - 200 - 8))
|
||||
: tooltip.x + 10,
|
||||
top: tooltip.y - 30,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<TooltipDayAction onOpen={() => open(tooltip.day as DateKey)} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { cellProps, svgProps, tooltipNode, openKey, coarse };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -27,6 +27,19 @@ const WHOLE_NUMBER_TYPES: ReadonlySet<string> = new Set([
|
||||
"RESPIRATORY_RATE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Two spellings of one unit: the day route sends a duration in "min", the
|
||||
* client's unit map knows it as "minutes". Read as the same unit, so a night
|
||||
* reads as "6 h 52 min" and not as a bare 412.
|
||||
*/
|
||||
const UNIT_ALIASES: Readonly<Record<string, string>> = { min: "minutes" };
|
||||
|
||||
/** True when the row carries the type's canonical unit, in either spelling. */
|
||||
function isCanonicalUnit(row: Pick<DayValue, "type" | "unit">): boolean {
|
||||
const canonical = getUnitForType(row.type);
|
||||
return row.unit === canonical || UNIT_ALIASES[row.unit] === canonical;
|
||||
}
|
||||
|
||||
/** True for a type read in whole numbers (counts, pressures, pulses). */
|
||||
export function isWholeNumberType(type: string): boolean {
|
||||
return WHOLE_NUMBER_TYPES.has(type);
|
||||
@@ -68,7 +81,7 @@ export function useDayValueFormat() {
|
||||
|
||||
const number = useCallback(
|
||||
(row: Pick<DayValue, "type" | "unit">, raw: number): string => {
|
||||
const canonical = row.unit === getUnitForType(row.type);
|
||||
const canonical = isCanonicalUnit(row);
|
||||
const shown = canonical ? units.toDisplay(row.type, raw) : raw;
|
||||
if (row.type === "SLEEP_DURATION" && canonical) {
|
||||
return formatDurationMinutes(shown, t);
|
||||
@@ -89,10 +102,11 @@ export function useDayValueFormat() {
|
||||
const unitFor = useCallback(
|
||||
(row: Pick<DayValue, "type" | "unit">): string => {
|
||||
if (row.type === "SLEEP_DURATION") return "";
|
||||
if (row.unit === getUnitForType(row.type)) return units.unitFor(row.type);
|
||||
return row.unit;
|
||||
const unit = isCanonicalUnit(row) ? units.unitFor(row.type) : row.unit;
|
||||
// A device score reads in points, as on its chart.
|
||||
return unit === "score" ? t("insights.deviceScore.unitScore") : unit;
|
||||
},
|
||||
[units],
|
||||
[t, units],
|
||||
);
|
||||
|
||||
const formatTile = useCallback(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
@@ -18,15 +19,34 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* running-day counts and the notable observations. Nothing here recomputes
|
||||
* any of it.
|
||||
*/
|
||||
export function useDay(date: DateKey | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.day(date ?? ""),
|
||||
function dayQuery(date: DateKey) {
|
||||
return {
|
||||
queryKey: queryKeys.day(date),
|
||||
queryFn: () => apiGet<DayResponse>(`/api/day/${date}`),
|
||||
enabled: date !== null,
|
||||
// A past day changes only when something is written into it, and every
|
||||
// record write evicts the `["day"]` root.
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function useDay(date: DateKey | null) {
|
||||
return useQuery({ ...dayQuery(date ?? ""), enabled: date !== null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a day ahead of time, so the step to it paints the day instead of a
|
||||
* skeleton: the neighbouring day when the pointer rests on, or the keyboard
|
||||
* reaches, the arrow that leads there. A day already read (and fresh) is not
|
||||
* read again; the query layer dedupes one that is already on its way.
|
||||
*/
|
||||
export function usePrefetchDay(): (date: DateKey) => void {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(
|
||||
(date: DateKey) => {
|
||||
void queryClient.prefetchQuery(dayQuery(date));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,6 +90,10 @@ export function DocumentBulkBar({
|
||||
aria-label={t("documents.bulk.barLabel")}
|
||||
className={cn(
|
||||
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
|
||||
// v1.42 — while a day is docked on the right, the bar sits against
|
||||
// the page's right edge instead of the window's centre, clear of the
|
||||
// day's footer actions and of an expanded sidebar.
|
||||
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
|
||||
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -641,7 +641,10 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
const rest = atRest(html);
|
||||
// The onset is an instant; it reads as the day it falls on in the
|
||||
// profile timezone, in the user's date format, never as raw ISO.
|
||||
expect(rest).toMatch(/Rest Mode active since \d{2}\/\d{2}\/2026\./);
|
||||
// v1.42 — the date is a link to its day; the sentence reads the same.
|
||||
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
|
||||
/Rest Mode active since \d{2}\/\d{2}\/2026\./,
|
||||
);
|
||||
expect(rest).not.toContain("T23:30:00");
|
||||
expect(rest).toContain('data-slot="health-score-tension"');
|
||||
expect(rest).toContain('data-slot="health-score-return-to-band"');
|
||||
@@ -669,7 +672,11 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
/>,
|
||||
);
|
||||
const rest = atRest(html);
|
||||
expect(rest).toMatch(/Rest Mode active since (07\/20\/2026|20\/07\/2026)/);
|
||||
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
|
||||
/Rest Mode active since (07\/20\/2026|20\/07\/2026)/,
|
||||
);
|
||||
// The date opens the day it names, in the profile timezone.
|
||||
expect(rest).toContain('data-day="2026-07-20"');
|
||||
expect(rest).not.toContain("2026-07-19T");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { DayLinkAt } from "@/components/day/day-link";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -111,7 +114,12 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.recorded")}
|
||||
value={fmt.dateTime(new Date(data.recordedAt))}
|
||||
// v1.42 — the date opens the day around the recording.
|
||||
value={
|
||||
<DayLinkAt at={data.recordedAt}>
|
||||
{fmt.dateTime(new Date(data.recordedAt))}
|
||||
</DayLinkAt>
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.duration")}
|
||||
@@ -149,7 +157,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
function MetaItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit";
|
||||
import { DayLinkAt } from "@/components/day/day-link";
|
||||
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
// Re-exported so the tests (and any teaser call site) can keep importing the
|
||||
@@ -140,7 +142,11 @@ export function EcgSection({
|
||||
the recording, it is not a second fact about it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
{/* v1.42 — the date opens the day; the rest of the
|
||||
row opens the recording. Above the row's link. */}
|
||||
<DayLinkAt at={rec.recordedAt} className="z-10">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
</DayLinkAt>
|
||||
</p>
|
||||
{label && (
|
||||
<span
|
||||
@@ -166,19 +172,32 @@ export function EcgSection({
|
||||
key={rec.id}
|
||||
data-slot="ecg-row"
|
||||
data-classification={rec.classification ?? "NONE"}
|
||||
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
className="border-border/60 relative border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{rec.hasWaveform ? (
|
||||
<Link
|
||||
href={`/insights/ecg/${rec.id}`}
|
||||
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
|
||||
>
|
||||
// v1.42 — two targets in one row: the date opens the day,
|
||||
// the row opens the recording. The row's link is stretched
|
||||
// under the content (the workout list's pattern), so the
|
||||
// date can sit above it instead of inside it.
|
||||
<div className="has-[a[data-slot=ecg-row-link]:hover]:bg-muted/40 has-[a[data-slot=ecg-row-link]:focus-visible]:ring-ring/50 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors has-[a[data-slot=ecg-row-link]:focus-visible]:ring-2">
|
||||
{rowInner}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground mt-1.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/insights/ecg/${rec.id}`}
|
||||
data-slot="ecg-row-link"
|
||||
aria-label={[
|
||||
fmt.dateTime(new Date(rec.recordedAt)),
|
||||
label,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
className="mt-1.5 shrink-0 after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
|
||||
>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type PillarDetailContext,
|
||||
} from "@/components/insights/health-score-pillar-detail";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
DAY_LINK_SLOT,
|
||||
DayLink,
|
||||
withDayLinkSlot,
|
||||
} from "@/components/day/day-link";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
@@ -530,18 +535,30 @@ export function HealthScoreCard({
|
||||
data-slot="health-score-rest-mode"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.healthScore.restMode", {
|
||||
since: report.restMode.since
|
||||
? formatDate(
|
||||
userDayKey(
|
||||
{/* v1.42 — the date opens the day rest mode began. */}
|
||||
{report.restMode.since
|
||||
? withDayLinkSlot(
|
||||
t("insights.healthScore.restMode", { since: DAY_LINK_SLOT }),
|
||||
<DayLink
|
||||
date={userDayKey(
|
||||
new Date(report.restMode.since),
|
||||
user?.timezone || DEFAULT_TIMEZONE,
|
||||
),
|
||||
dateFormat,
|
||||
locale,
|
||||
)
|
||||
: t("insights.healthScore.none"),
|
||||
})}
|
||||
)}
|
||||
size="xs"
|
||||
>
|
||||
{formatDate(
|
||||
userDayKey(
|
||||
new Date(report.restMode.since),
|
||||
user?.timezone || DEFAULT_TIMEZONE,
|
||||
),
|
||||
dateFormat,
|
||||
locale,
|
||||
)}
|
||||
</DayLink>,
|
||||
)
|
||||
: t("insights.healthScore.restMode", {
|
||||
since: t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{tensionLine ? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -250,7 +250,12 @@ export function LayoutCoachFab() {
|
||||
// The default ring alone is hard to see against the gradient;
|
||||
// the offset ring draws a clear halo around the circle.
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
"transition-[opacity,right] duration-150 motion-reduce:transition-none",
|
||||
// v1.42 — while a day is docked on the right (from 1280 px), the
|
||||
// launcher moves left with the page's own edge, so it stays over
|
||||
// the page and never over the day's footer actions. The `:has()`
|
||||
// gate keys off the docked panel's slot, like the yields below.
|
||||
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]",
|
||||
// Yield to the data-list selection bar: its delete action lands
|
||||
// in the same lower-right band, and the destructive control
|
||||
// wins. The `:has()` gate keys off the bar's `data-slot`.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -81,6 +81,7 @@ export function CaffeineCard() {
|
||||
unit={data.unit}
|
||||
valueLabel={t("nutrients.names.caffeine")}
|
||||
referenceValue={data.reference?.value ?? null}
|
||||
dayLinks
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -78,6 +78,7 @@ export function HydrationCard() {
|
||||
unit={data.unit}
|
||||
valueLabel={t("nutrients.names.water")}
|
||||
referenceValue={data.reference?.value ?? null}
|
||||
dayLinks
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -99,6 +99,9 @@ export function RecoveryMetricBlock({
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
// v1.42 — the same doors to the day every metric page's chart has.
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
|
||||
<MetricStatusCard
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { HeartPulse } from "lucide-react";
|
||||
|
||||
import {
|
||||
DAY_LINK_SLOT,
|
||||
DayLinkStated,
|
||||
withDayLinkSlot,
|
||||
} from "@/components/day/day-link";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
|
||||
|
||||
@@ -62,8 +67,14 @@ export function RestModeBanner({
|
||||
{t("insights.restMode.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{sinceLabel
|
||||
? t("insights.restMode.bodySince", { since: sinceLabel })
|
||||
{/* v1.42 — the onset date opens the day the episode began. */}
|
||||
{sinceLabel && since
|
||||
? withDayLinkSlot(
|
||||
t("insights.restMode.bodySince", { since: DAY_LINK_SLOT }),
|
||||
<DayLinkStated at={since} size="xs">
|
||||
{sinceLabel}
|
||||
</DayLinkStated>,
|
||||
)
|
||||
: t("insights.restMode.body")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { DayLinkAt } from "@/components/day/day-link";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
@@ -174,7 +175,10 @@ export function RhythmEventsCard({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(event.occurredAt))}
|
||||
{/* v1.42 — the date opens the day around the event. */}
|
||||
<DayLinkAt at={event.occurredAt} size="xs">
|
||||
{fmt.dateTime(new Date(event.occurredAt))}
|
||||
</DayLinkAt>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -47,6 +47,9 @@ export function SleepDurationChart({
|
||||
yAxisUnit="h"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={userTimezone}
|
||||
// v1.42 — the same doors to the day every metric page's chart has.
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -14,9 +15,18 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChartDataTable } from "@/components/charts/chart-data-table";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
dayAnchor,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
@@ -260,6 +270,8 @@ export function compositionTotals(row: CompositionRow | undefined): {
|
||||
|
||||
export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const formatCalendar = useCalendarDate();
|
||||
const fmtDay = (date: Date) => formatCalendar(dateOnlyKey(date));
|
||||
|
||||
// v1.4.25 W3f — window toggle (7 / 14 / 30). Default 7d so the user
|
||||
// sees their most recent week with maximal per-bar resolution.
|
||||
@@ -313,6 +325,36 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
),
|
||||
);
|
||||
|
||||
// v1.42 — each night's column opens the day it ended on (the day the
|
||||
// per-night key names, as `/api/sleep/night` does), through the same doors
|
||||
// every day-linked chart has.
|
||||
const nightDays = data.map((row) =>
|
||||
typeof row.dayKey === "string" && row.dayKey !== "aggregate"
|
||||
? row.dayKey
|
||||
: null,
|
||||
);
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: hasData && nightDays.some((day) => day !== null),
|
||||
days: nightDays,
|
||||
focusFor: (index) => {
|
||||
const totals = compositionTotals(data[index]);
|
||||
return totals.nightMinutes > 0
|
||||
? {
|
||||
label: t("insights.sleep.mainSleep"),
|
||||
value: formatDurationMinutes(totals.nightMinutes, t),
|
||||
types: ["SLEEP_DURATION"],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const tableColumns = [...STAGE_ORDER, ...(hasNap ? [NAP_KEY] : [])]
|
||||
.filter((stage) =>
|
||||
data.some(
|
||||
(row) => typeof row[stage] === "number" && (row[stage] as number) > 0,
|
||||
),
|
||||
)
|
||||
.map((stage) => ({ key: stage, label: stageLabels[stage] ?? stage }));
|
||||
|
||||
const ariaLabel = t("insights.sleep.compositionAriaLabel", {
|
||||
nights: breakdown.nights,
|
||||
});
|
||||
@@ -369,157 +411,228 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
{t("insights.sleep.stages.unavailable")}
|
||||
</p>
|
||||
) : (
|
||||
<div role="img" aria-label={ariaLabel}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
tickFormatter={(v: number) => {
|
||||
// Render the y-axis as hours so 480 min reads as 8h.
|
||||
if (v <= 0) return "0";
|
||||
return `${Math.round(v / 60)}h`;
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "transparent" }}
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || payload.length === 0)
|
||||
return null;
|
||||
const row = payload[0]?.payload as
|
||||
Record<string, number | string> | undefined;
|
||||
const napCount =
|
||||
typeof row?.napCount === "number" ? row.napCount : 0;
|
||||
// The footer's first line is the NIGHT. The nap is listed
|
||||
// above it as its own line and stays out of that sum, so
|
||||
// the per-stage percentages keep meaning "of this night".
|
||||
// On a day with a nap a second line carries the whole
|
||||
// column — main sleep plus naps — the Main sleep / Naps /
|
||||
// Total split issue #611 asked for.
|
||||
const totals = compositionTotals(row);
|
||||
return (
|
||||
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="border-border mb-1 border-b pb-1 font-medium">
|
||||
{label}
|
||||
</div>
|
||||
{payload.map((entry) => {
|
||||
const stage = String(entry.dataKey ?? "");
|
||||
const minutes =
|
||||
typeof entry.value === "number" ? entry.value : 0;
|
||||
if (minutes === 0) return null;
|
||||
const isNap = stage === NAP_KEY;
|
||||
const label = isNap
|
||||
? napCount > 1
|
||||
? t("insights.sleep.stages.napCount", {
|
||||
count: String(napCount),
|
||||
})
|
||||
: stageLabels[NAP_KEY]
|
||||
: (stageLabels[stage] ?? stage);
|
||||
const pct =
|
||||
totals.nightMinutes > 0
|
||||
? Math.round(
|
||||
(minutes / totals.nightMinutes) * 100,
|
||||
)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={stage}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ background: STAGE_COLORS[stage] }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{/* The nap is not part of the night, so it
|
||||
<>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
type="category"
|
||||
// The day key, so the open day's line can name its column;
|
||||
// the tick prints the window's label for it.
|
||||
dataKey="dayKey"
|
||||
tickFormatter={(dayKey: string) =>
|
||||
dayKey === "aggregate"
|
||||
? t("insights.sleep.compositionTitle")
|
||||
: formatDayTick(dayKey, windowDays, locale)
|
||||
}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
tickFormatter={(v: number) => {
|
||||
// Render the y-axis as hours so 480 min reads as 8h.
|
||||
if (v <= 0) return "0";
|
||||
return `${Math.round(v / 60)}h`;
|
||||
}}
|
||||
/>
|
||||
{chartDays.openIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
x={nightDays[chartDays.openIndex] ?? undefined}
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
cursor={{ fill: "transparent" }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0)
|
||||
return null;
|
||||
const row = payload[0]?.payload as
|
||||
Record<string, number | string> | undefined;
|
||||
const label = row?.label;
|
||||
const rowIndex = row ? data.indexOf(row) : -1;
|
||||
const napCount =
|
||||
typeof row?.napCount === "number" ? row.napCount : 0;
|
||||
// The footer's first line is the NIGHT. The nap is listed
|
||||
// above it as its own line and stays out of that sum, so
|
||||
// the per-stage percentages keep meaning "of this night".
|
||||
// On a day with a nap a second line carries the whole
|
||||
// column — main sleep plus naps — the Main sleep / Naps /
|
||||
// Total split issue #611 asked for.
|
||||
const totals = compositionTotals(row);
|
||||
return (
|
||||
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="border-border mb-1 border-b pb-1 font-medium">
|
||||
{label}
|
||||
</div>
|
||||
{payload.map((entry) => {
|
||||
const stage = String(entry.dataKey ?? "");
|
||||
const minutes =
|
||||
typeof entry.value === "number" ? entry.value : 0;
|
||||
if (minutes === 0) return null;
|
||||
const isNap = stage === NAP_KEY;
|
||||
const label = isNap
|
||||
? napCount > 1
|
||||
? t("insights.sleep.stages.napCount", {
|
||||
count: String(napCount),
|
||||
})
|
||||
: stageLabels[NAP_KEY]
|
||||
: (stageLabels[stage] ?? stage);
|
||||
const pct =
|
||||
totals.nightMinutes > 0
|
||||
? Math.round(
|
||||
(minutes / totals.nightMinutes) * 100,
|
||||
)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={stage}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ background: STAGE_COLORS[stage] }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{/* The nap is not part of the night, so it
|
||||
gets no share-of-night percentage. */}
|
||||
{isNap
|
||||
? formatDurationMinutes(minutes, t)
|
||||
: `${formatDurationMinutes(minutes, t)} · ${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{totals.nightMinutes > 0 && (
|
||||
<div className="border-border mt-1 border-t pt-1 font-medium">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{t("insights.sleep.mainSleep")}</span>
|
||||
<span>
|
||||
{formatDurationMinutes(totals.nightMinutes, t)}
|
||||
</span>
|
||||
</div>
|
||||
{totals.napMinutes > 0 && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{t("insights.sleep.totalSleep")}</span>
|
||||
<span>
|
||||
{formatDurationMinutes(totals.dayMinutes, t)}
|
||||
{isNap
|
||||
? formatDurationMinutes(minutes, t)
|
||||
: `${formatDurationMinutes(minutes, t)} · ${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value: string) => stageLabels[value] ?? value}
|
||||
/>
|
||||
{STAGE_ORDER.map((stage) => (
|
||||
<Bar
|
||||
key={stage}
|
||||
dataKey={stage}
|
||||
stackId="stages"
|
||||
fill={STAGE_COLORS[stage]}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<Cell fill={STAGE_COLORS[stage]} />
|
||||
</Bar>
|
||||
))}
|
||||
{hasNap && (
|
||||
<Bar
|
||||
key={NAP_KEY}
|
||||
dataKey={NAP_KEY}
|
||||
stackId="stages"
|
||||
fill={STAGE_COLORS[NAP_KEY]}
|
||||
// The outline is what separates the nap from the stage
|
||||
// beneath it; the two fills alone measure as low as
|
||||
// 1.04:1. See NAP_SEPARATOR.
|
||||
stroke={NAP_SEPARATOR.stroke}
|
||||
strokeWidth={NAP_SEPARATOR.strokeWidth}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<Cell
|
||||
);
|
||||
})}
|
||||
{totals.nightMinutes > 0 && (
|
||||
<div className="border-border mt-1 border-t pt-1 font-medium">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{t("insights.sleep.mainSleep")}</span>
|
||||
<span>
|
||||
{formatDurationMinutes(
|
||||
totals.nightMinutes,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{totals.napMinutes > 0 && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{t("insights.sleep.totalSleep")}</span>
|
||||
<span>
|
||||
{formatDurationMinutes(
|
||||
totals.dayMinutes,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{chartDays.tooltipAction(
|
||||
rowIndex === -1 ? undefined : rowIndex,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value: string) => stageLabels[value] ?? value}
|
||||
/>
|
||||
{STAGE_ORDER.map((stage) => (
|
||||
<Bar
|
||||
key={stage}
|
||||
dataKey={stage}
|
||||
stackId="stages"
|
||||
fill={STAGE_COLORS[stage]}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<Cell fill={STAGE_COLORS[stage]} />
|
||||
</Bar>
|
||||
))}
|
||||
{hasNap && (
|
||||
<Bar
|
||||
key={NAP_KEY}
|
||||
dataKey={NAP_KEY}
|
||||
stackId="stages"
|
||||
fill={STAGE_COLORS[NAP_KEY]}
|
||||
// The outline is what separates the nap from the stage
|
||||
// beneath it; the two fills alone measure as low as
|
||||
// 1.04:1. See NAP_SEPARATOR.
|
||||
stroke={NAP_SEPARATOR.stroke}
|
||||
strokeWidth={NAP_SEPARATOR.strokeWidth}
|
||||
/>
|
||||
</Bar>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<Cell
|
||||
fill={STAGE_COLORS[NAP_KEY]}
|
||||
stroke={NAP_SEPARATOR.stroke}
|
||||
strokeWidth={NAP_SEPARATOR.strokeWidth}
|
||||
/>
|
||||
</Bar>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={data.map((row) => ({
|
||||
timestamp:
|
||||
typeof row.dayKey === "string" && row.dayKey !== "aggregate"
|
||||
? dayAnchor(row.dayKey)
|
||||
: 0,
|
||||
}))}
|
||||
axis="band"
|
||||
mark="bar"
|
||||
// The plot's margin (8) + the y axis (60) on the left, the
|
||||
// margin (16) on the right.
|
||||
insetLeft={8 + 60}
|
||||
insetRight={16}
|
||||
/>
|
||||
{chartDays.active ? (
|
||||
<ChartDataTable
|
||||
points={data.map((row) => ({
|
||||
date: String(row.dayKey),
|
||||
timestamp: dayAnchor(String(row.dayKey)),
|
||||
...Object.fromEntries(
|
||||
tableColumns.map(({ key }) => [
|
||||
key,
|
||||
typeof row[key] === "number" && (row[key] as number) > 0
|
||||
? (row[key] as number)
|
||||
: undefined,
|
||||
]),
|
||||
),
|
||||
}))}
|
||||
columns={tableColumns}
|
||||
formatValue={(minutes) => formatDurationMinutes(minutes, t)}
|
||||
formatDate={(date) => fmtDay(date)}
|
||||
bucket="day"
|
||||
metricLabel={t("insights.sleep.compositionTitle")}
|
||||
dayLinks
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -26,8 +26,11 @@ import {
|
||||
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
|
||||
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import { TooltipDayAction, useCoarsePointer } from "../day/chart-day";
|
||||
import { openDay, useOpenDay } from "../day/day-layer-controller";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "../day/chart-day-links";
|
||||
import { dateKeyOfInstant } from "../day/day-url";
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
@@ -114,45 +117,13 @@ export function LabBiomarkerChart({
|
||||
: fmt.dateShortSmart(instant);
|
||||
};
|
||||
const [range, setRange] = useState<RangeKey>("365");
|
||||
// v1.42 — a reading opens its day: a click on a fine pointer, the
|
||||
// tooltip's button on a touch screen. The open day's reading is ringed.
|
||||
const timeZone = useDisplayTimezone();
|
||||
const coarse = useCoarsePointer();
|
||||
const openDayKey = useOpenDay();
|
||||
const dayOf = (timestamp: number) => {
|
||||
const instant = new Date(timestamp);
|
||||
return isNoonUtcAnchor(instant)
|
||||
? dateOnlyKey(instant)
|
||||
: dateKeyOfInstant(instant, timeZone);
|
||||
};
|
||||
const openReadingDay = (point: ChartPoint | undefined) => {
|
||||
if (!point) return;
|
||||
const ordered = [...readings]
|
||||
.filter((r) => r.value !== null)
|
||||
.sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const at = ordered.findIndex((r) => r.id === point.id);
|
||||
const before = at > 0 ? ordered[at - 1] : undefined;
|
||||
const date = dayOf(point.timestamp);
|
||||
openDay(date, {
|
||||
focus: {
|
||||
date,
|
||||
label: readings[0]?.analyte ?? "",
|
||||
value: labNumber(point.value),
|
||||
unit,
|
||||
compare:
|
||||
before && before.value !== null
|
||||
? {
|
||||
label: t("day.previousReading", {
|
||||
date: labShortDate(before.takenAt),
|
||||
}),
|
||||
value: `${labNumber(before.value)} ${unit}`.trim(),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
// Capture "now" once at mount so the recency filter stays pure across
|
||||
// re-renders (an inline `Date.now()` in render is impure).
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
@@ -230,6 +201,43 @@ export function LabBiomarkerChart({
|
||||
return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad];
|
||||
}, [points, lowerBound, upperBound, sourceBand]);
|
||||
|
||||
// v1.42 — a reading opens its day through the doors every day-linked
|
||||
// chart has; the day opens with the reading on top and the one before it
|
||||
// beside it. The open day's reading is ringed and its line dashed.
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: true,
|
||||
days: points.map((point) => dayOf(point.timestamp)),
|
||||
focusFor: (index) => {
|
||||
const point = points[index];
|
||||
if (!point) return null;
|
||||
// The reading before it, in or out of the range in view.
|
||||
const ordered = [...readings]
|
||||
.filter((r) => r.value !== null)
|
||||
.sort(
|
||||
(x, y) =>
|
||||
new Date(x.takenAt).getTime() - new Date(y.takenAt).getTime(),
|
||||
);
|
||||
const at = ordered.findIndex((r) => r.id === point.id);
|
||||
const before = at > 0 ? ordered[at - 1] : undefined;
|
||||
return {
|
||||
label: readings[0]?.analyte ?? "",
|
||||
value: labNumber(point.value),
|
||||
unit,
|
||||
compare:
|
||||
before && before.value !== null
|
||||
? {
|
||||
label: t("day.previousReading", {
|
||||
date: labShortDate(before.takenAt),
|
||||
}),
|
||||
value: `${labNumber(before.value)} ${unit}`.trim(),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
const openPoint =
|
||||
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
// A second muted hue from the chart palette. Distinct from the primary band
|
||||
@@ -260,188 +268,187 @@ export function LabBiomarkerChart({
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
className={coarse ? undefined : "cursor-pointer"}
|
||||
onClick={
|
||||
coarse
|
||||
? undefined
|
||||
: (state) => {
|
||||
const index = Number(state?.activeTooltipIndex);
|
||||
if (Number.isInteger(index))
|
||||
openReadingDay(points[index]);
|
||||
}
|
||||
}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => labShortDate(ts)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => labNumber(v)}
|
||||
/>
|
||||
{/* Reference window — muted band + dashed bounds. No alarm
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => labShortDate(ts)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => labNumber(v)}
|
||||
/>
|
||||
{/* Reference window — muted band + dashed bounds. No alarm
|
||||
colour: the in/out verdict is shown by the neutral badge,
|
||||
not by painting the chart red. */}
|
||||
{lowerBound != null && upperBound != null ? (
|
||||
<ReferenceArea
|
||||
y1={lowerBound}
|
||||
y2={upperBound}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
{lowerBound != null ? (
|
||||
<ReferenceLine
|
||||
y={lowerBound}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{upperBound != null ? (
|
||||
<ReferenceLine
|
||||
y={upperBound}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{/* The source window — outlined, not filled, so it reads as a
|
||||
{lowerBound != null && upperBound != null ? (
|
||||
<ReferenceArea
|
||||
y1={lowerBound}
|
||||
y2={upperBound}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
{lowerBound != null ? (
|
||||
<ReferenceLine
|
||||
y={lowerBound}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{upperBound != null ? (
|
||||
<ReferenceLine
|
||||
y={upperBound}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{/* The source window — outlined, not filled, so it reads as a
|
||||
second band over the catalog one rather than a second fill
|
||||
competing with it. Same no-alarm-colour rule. */}
|
||||
{sourceBand &&
|
||||
sourceBand.low != null &&
|
||||
sourceBand.high != null ? (
|
||||
<ReferenceArea
|
||||
y1={sourceBand.low}
|
||||
y2={sourceBand.high}
|
||||
fill={secondary}
|
||||
fillOpacity={0.06}
|
||||
stroke={secondary}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
) : null}
|
||||
{sourceBand &&
|
||||
sourceBand.low != null &&
|
||||
sourceBand.high == null ? (
|
||||
<ReferenceLine
|
||||
y={sourceBand.low}
|
||||
stroke={secondary}
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
) : null}
|
||||
{sourceBand &&
|
||||
sourceBand.high != null &&
|
||||
sourceBand.low == null ? (
|
||||
<ReferenceLine
|
||||
y={sourceBand.high}
|
||||
stroke={secondary}
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
trigger={coarse ? "click" : "hover"}
|
||||
wrapperStyle={
|
||||
coarse ? { pointerEvents: "auto", zIndex: 20 } : undefined
|
||||
}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("labs.chart.valueLabel"),
|
||||
value: `${labNumber(point.value)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
// Name the window THIS reading was judged against. Two
|
||||
// readings on one chart can carry different printed windows,
|
||||
// so the band alone cannot answer it per point.
|
||||
if (point.referenceOrigin === "source") {
|
||||
rows.push({
|
||||
name: t("labs.chart.sourceRangeLabel"),
|
||||
value:
|
||||
point.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
point.referenceLow,
|
||||
point.referenceHigh,
|
||||
labNumber,
|
||||
)} ${unit}`.trim(),
|
||||
color: secondary,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={labShortDate(point.timestamp)}
|
||||
rows={rows}
|
||||
action={
|
||||
coarse ? (
|
||||
<TooltipDayAction
|
||||
onOpen={() => openReadingDay(point)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{points
|
||||
.filter(
|
||||
(point) =>
|
||||
openDayKey !== null &&
|
||||
dayOf(point.timestamp) === openDayKey,
|
||||
)
|
||||
.map((point) => (
|
||||
<ReferenceDot
|
||||
key={`open-day-${point.id}`}
|
||||
x={point.timestamp}
|
||||
y={point.value}
|
||||
r={6.5}
|
||||
fill="var(--card)"
|
||||
stroke={primary}
|
||||
strokeWidth={2.5}
|
||||
ifOverflow="discard"
|
||||
{sourceBand &&
|
||||
sourceBand.low != null &&
|
||||
sourceBand.high != null ? (
|
||||
<ReferenceArea
|
||||
y1={sourceBand.low}
|
||||
y2={sourceBand.high}
|
||||
fill={secondary}
|
||||
fillOpacity={0.06}
|
||||
stroke={secondary}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
))}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : null}
|
||||
{sourceBand &&
|
||||
sourceBand.low != null &&
|
||||
sourceBand.high == null ? (
|
||||
<ReferenceLine
|
||||
y={sourceBand.low}
|
||||
stroke={secondary}
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
) : null}
|
||||
{sourceBand &&
|
||||
sourceBand.high != null &&
|
||||
sourceBand.low == null ? (
|
||||
<ReferenceLine
|
||||
y={sourceBand.high}
|
||||
stroke={secondary}
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
) : null}
|
||||
{openPoint ? (
|
||||
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("labs.chart.valueLabel"),
|
||||
value: `${labNumber(point.value)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
// Name the window THIS reading was judged against. Two
|
||||
// readings on one chart can carry different printed windows,
|
||||
// so the band alone cannot answer it per point.
|
||||
if (point.referenceOrigin === "source") {
|
||||
rows.push({
|
||||
name: t("labs.chart.sourceRangeLabel"),
|
||||
value:
|
||||
point.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
point.referenceLow,
|
||||
point.referenceHigh,
|
||||
labNumber,
|
||||
)} ${unit}`.trim(),
|
||||
color: secondary,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={labShortDate(point.timestamp)}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{points
|
||||
.filter(
|
||||
(point) =>
|
||||
chartDays.openDay !== null &&
|
||||
dayOf(point.timestamp) === chartDays.openDay,
|
||||
)
|
||||
.map((point) => (
|
||||
<ReferenceDot
|
||||
key={`open-day-${point.id}`}
|
||||
x={point.timestamp}
|
||||
y={point.value}
|
||||
r={6.5}
|
||||
fill="var(--card)"
|
||||
stroke={primary}
|
||||
strokeWidth={2.5}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points}
|
||||
axis="time"
|
||||
// The y axis (44) on the left, the margin (12) on the right.
|
||||
insetLeft={44}
|
||||
insetRight={12}
|
||||
/>
|
||||
{sourceBand ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{sourceBand.mixed
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -34,7 +35,17 @@ import {
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "../day/chart-day-links";
|
||||
import { dateKeyOfInstant } from "../day/day-url";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
import type { AssessmentRow, InstrumentId } from "./types";
|
||||
@@ -69,6 +80,26 @@ export function AssessmentHistoryChart({
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
// v1.42 — each result opens the day it was taken on, through the doors
|
||||
// every day-linked chart has. The dated list under the chart is the
|
||||
// keyboard way to the same days.
|
||||
const timeZone = useDisplayTimezone();
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: true,
|
||||
days: points.map((point) => dateKeyOfInstant(point.timestamp, timeZone)),
|
||||
focusFor: (index) => {
|
||||
const point = points[index];
|
||||
return point
|
||||
? {
|
||||
label: t(`mentalHealth.instrument.${def.i18nKey}`),
|
||||
value: String(point.value),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const openPoint =
|
||||
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
const gradientId = `mh-trend-${instrument}`;
|
||||
@@ -82,86 +113,108 @@ export function AssessmentHistoryChart({
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
<div>
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
|
||||
<stop offset="95%" stopColor={primary} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, def.maxScore]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rowsOut: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("mentalHealth.history.totalLabel"),
|
||||
value: String(point.value),
|
||||
color: primary,
|
||||
},
|
||||
{
|
||||
name: t("mentalHealth.history.bandLabel"),
|
||||
value: t(`mentalHealth.band.${instrument}.${point.band}`),
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
|
||||
<stop offset="95%" stopColor={primary} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, def.maxScore]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={32}
|
||||
/>
|
||||
{openPoint ? (
|
||||
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rowsOut: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("mentalHealth.history.totalLabel"),
|
||||
value: String(point.value),
|
||||
color: primary,
|
||||
},
|
||||
{
|
||||
name: t("mentalHealth.history.bandLabel"),
|
||||
value: t(`mentalHealth.band.${instrument}.${point.band}`),
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points}
|
||||
axis="time"
|
||||
// The margin (8) + the y axis (32) on the left, the margin on the right.
|
||||
insetLeft={8 + 32}
|
||||
insetRight={8}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
|
||||
import { Activity, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DayLinkAt } from "@/components/day/day-link";
|
||||
|
||||
import { AssessmentItemBreakdown } from "./assessment-item-breakdown";
|
||||
import { CrisisCard } from "./crisis-card";
|
||||
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
|
||||
@@ -132,9 +134,11 @@ export function AssessmentHistory({
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{/* v1.42 — the date opens the day around the result: the
|
||||
keyboard way to the days the chart above opens. */}
|
||||
<DayLinkAt at={row.takenAt}>
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
</DayLinkAt>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
|
||||
@@ -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