mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge remote-tracking branch 'origin/release/v1.42.0' into v142/qol-1
# Conflicts: # src/app/insights/mood/page.tsx # src/components/charts/mood-heatmap.tsx # src/components/documents/document-bulk-bar.tsx
This commit is contained in:
commit
17b8b35397
67 files changed
+3990
-1675
No files matched your search
@@ -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, useCoarsePointer } 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";
|
||||
@@ -98,14 +100,15 @@ export function MoodHeatmapBody({
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading shape of the stretched heatmap: the reserved grid box and the
|
||||
* legend line, at exactly the height the painted heatmap will take.
|
||||
* `days` is the window when it is already known; without it the shape
|
||||
* assumes a window short enough to reach the cell ceiling, which every
|
||||
* window under a quarter year does on any width.
|
||||
* The loading shape of the stretched heatmap, at exactly the height the
|
||||
* painted heatmap takes: the reserved grid box, the legend line, the day
|
||||
* caption and the data-table toggle. `days` is the window when it is already
|
||||
* known; without it the shape assumes a window short enough to reach the cell
|
||||
* ceiling, which every window under a quarter year does on any width.
|
||||
*/
|
||||
export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) {
|
||||
const timeZone = useDisplayTimezone();
|
||||
const coarse = useCoarsePointer();
|
||||
return (
|
||||
<div
|
||||
data-slot="mood-heatmap-skeleton"
|
||||
@@ -116,6 +119,10 @@ export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) {
|
||||
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
</MoodHeatmapBody>
|
||||
<div className="mt-2 h-4" />
|
||||
<ChartDayCaption coarse={coarse} mark="cell" rug={false} />
|
||||
<div className="border-border mt-3 border-t pt-3">
|
||||
<div className="min-h-11" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,47 +153,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"),
|
||||
@@ -298,9 +268,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")}`;
|
||||
@@ -309,7 +278,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);
|
||||
@@ -333,9 +302,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
|
||||
@@ -366,114 +333,52 @@ 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>
|
||||
</StretchFrame>
|
||||
|
||||
{/* 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"
|
||||
@@ -503,6 +408,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],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,7 +58,13 @@ export interface BulkEncounterOption {
|
||||
const VERB_CLASS = "min-h-11 min-w-11 sm:min-h-9 sm:min-w-9";
|
||||
|
||||
function VerbLabel({ children }: { children: string }) {
|
||||
return <span className="hidden lg:inline">{children}</span>;
|
||||
// Icon-only again while a day is docked: the bar then shares the width
|
||||
// with the day panel and the labelled row would no longer fit one line.
|
||||
return (
|
||||
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentBulkBar({
|
||||
@@ -107,6 +113,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-3xl flex-col 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
|
||||
|
||||
Reference in new issue
Block a user