mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat: open the day from every chart drawn in days
Move the chart's doors to the day (click, the touch tooltip's button, the dashed line through the open day, the row of day dots, the caption) into one hook and give it to every chart with a date axis: recovery, sleep duration and stages, the mood line and calendar, the intake calendar, water and caffeine, lab markers, custom metrics, questionnaires and basal temperature. Heatmaps share one adapter; every chart's keyboard way to a day is its data table or the dated list beside it.
This commit is contained in:
1 parent
b5596f872d
commit
7fbde1bc83
21 files changed
+1725
-1000
No files matched your search
@@ -148,7 +148,7 @@ export default function InsightsStimmungPage() {
|
||||
MeasurementType series, so the period-over-period range read
|
||||
(`/api/analytics/range`, keyed on a MeasurementType enum) has
|
||||
nothing to aggregate. */}
|
||||
<MoodChart chartKey="mood" compareBaseline={compareBaseline} />
|
||||
<MoodChart chartKey="mood" compareBaseline={compareBaseline} dayLinks />
|
||||
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
|
||||
@@ -186,8 +186,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
|
||||
|
||||
@@ -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,32 @@ 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),
|
||||
rate:
|
||||
cell.data.expected > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((cell.data.taken / cell.data.expected) * 100),
|
||||
)
|
||||
: undefined,
|
||||
}))}
|
||||
columns={[{ key: "rate", label: t("medications.compliance") }]}
|
||||
formatValue={(value) => `${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,18 @@ 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 coarsePointer = chartDays.coarse;
|
||||
const openDayIndex = chartDays.openIndex;
|
||||
const openDayPoint =
|
||||
openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
|
||||
|
||||
const showContextDetails = showMA || showTrend || showBands;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
@@ -2025,11 +2021,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 +2031,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 +2201,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 +2261,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 +2405,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 +2545,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
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
type ChartBucketType,
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
@@ -102,6 +109,14 @@ interface MoodChartProps {
|
||||
* visibly before its measurement siblings. Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
/**
|
||||
* v1.42 — the chart is a door to its days, exactly as `HealthChart`'s
|
||||
* `dayLinks`: a click on a point (the tooltip's button on touch) opens the
|
||||
* day, the open day carries a dashed line, a row of dots marks the days
|
||||
* with entries, and the data table under the chart links each date. Daily
|
||||
* points only, never in `mini` mode.
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
@@ -317,6 +332,7 @@ export function MoodChart({
|
||||
compareBaseline = "none",
|
||||
chartKey,
|
||||
onDataReady,
|
||||
dayLinks = false,
|
||||
}: MoodChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -654,6 +670,27 @@ export function MoodChart({
|
||||
: sorted[mid];
|
||||
}, [chartData]);
|
||||
|
||||
// v1.42 — the chart as a door to its days (`dayLinks`), through the same
|
||||
// hook every day-linked chart uses.
|
||||
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
|
||||
const dayLinkSource = chartDataWithCompare ?? chartData;
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: dayLinksActive,
|
||||
days: dayLinksActive
|
||||
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
|
||||
: [],
|
||||
focusFor: (index) => {
|
||||
const point = dayLinkSource?.[index];
|
||||
return point
|
||||
? {
|
||||
label: displayTitle,
|
||||
value: formatTooltipValue(point.score),
|
||||
types: ["MOOD"],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// v1.16.8 — only an EMPTY success hides the card (the dashboard gates
|
||||
@@ -766,7 +803,8 @@ export function MoodChart({
|
||||
mini
|
||||
? "h-[var(--chart-height,140px)]"
|
||||
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
|
||||
} touch-pan-y`}
|
||||
} touch-pan-y ${chartDays.plotClassName}`}
|
||||
{...chartDays.plotProps}
|
||||
// Same stable hook as the measurement chart: rendered by the data
|
||||
// branch only, so the browser suite can gate on it instead of on
|
||||
// Recharts' own class names.
|
||||
@@ -779,6 +817,7 @@ export function MoodChart({
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
accessibilityLayer
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
|
||||
five mood-score ticks ([1,2,3,4,5]), which Recharts
|
||||
@@ -868,7 +907,15 @@ export function MoodChart({
|
||||
tickMargin={10}
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
{chartDays.openIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
key="open-day"
|
||||
x={chartDays.openIndex}
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
@@ -968,7 +1015,16 @@ export function MoodChart({
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return <RichChartTooltip active label={dateLabel} rows={rows} />;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(
|
||||
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
@@ -1142,7 +1198,36 @@ export function MoodChart({
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{chartBody}</CardContent>
|
||||
<CardContent>
|
||||
{chartBody}
|
||||
{!isLoading && !isError && chartData && chartData.length >= 3 ? (
|
||||
<>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={chartData}
|
||||
// The plot's margin (8) + the y axis (65) + the x axis padding
|
||||
// (10) on the left, margin + padding on the right.
|
||||
insetLeft={8 + 65 + 10}
|
||||
insetRight={8 + 10}
|
||||
/>
|
||||
{dayLinksActive ? (
|
||||
<ChartDataTable
|
||||
points={chartData.map((point) => ({
|
||||
date: point.date,
|
||||
timestamp: point.timestamp,
|
||||
score: point.score,
|
||||
}))}
|
||||
columns={[{ key: "score", label: t("charts.moodScore") }]}
|
||||
formatValue={formatTooltipValue}
|
||||
formatDate={tzFmt.date}
|
||||
bucket="day"
|
||||
metricLabel={displayTitle}
|
||||
dayLinks
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { openDay } from "@/components/day/day-layer-controller";
|
||||
import { isOpenableDay } from "@/components/day/day-url";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import { ChartDayCaption } from "@/components/day/chart-day";
|
||||
import { dayAnchor } from "@/components/day/chart-day-links";
|
||||
import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import { heatmapDays } from "@/lib/charts/heatmap-days";
|
||||
@@ -73,47 +75,10 @@ export function MoodHeatmap({
|
||||
const formatDay = useCalendarDate();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
pinned?: boolean;
|
||||
/** v1.42 — the day a pinned (touch) tooltip offers to open. */
|
||||
day?: string;
|
||||
} | null>(null);
|
||||
// v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
|
||||
// which then offers the day. The pointer type of the press decides.
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) {
|
||||
setTooltip(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
// 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip
|
||||
// (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's
|
||||
// "dismissible" requirement.
|
||||
useEffect(() => {
|
||||
if (!tooltip) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setTooltip(null);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [tooltip]);
|
||||
// v1.42 — the cells are doors to their days, the same doors every chart
|
||||
// has: a click (fine pointer), the pinned tooltip's button (touch), a
|
||||
// dashed outline on the open day, and the data table for the keyboard.
|
||||
const heatmapDay = useHeatmapDay(containerRef);
|
||||
|
||||
const WEEKDAY_LABELS = [
|
||||
t("charts.weekdays.mon"),
|
||||
@@ -225,9 +190,8 @@ export function MoodHeatmap({
|
||||
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
|
||||
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
|
||||
|
||||
// Per-day text alternative, shared by the pointer tooltip and the
|
||||
// visually-hidden day list below the grid (M2). One source of truth so
|
||||
// the sr-only list never drifts from what a pointer user reads.
|
||||
// Per-day text for the pointer tooltip; the data table below the grid
|
||||
// prints the same score with the same label (M2).
|
||||
const describeCell = (cell: (typeof cells)[number]): string => {
|
||||
if (!cell.cell) {
|
||||
return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
|
||||
@@ -236,7 +200,7 @@ export function MoodHeatmap({
|
||||
const moodLabel = labelKey ? t(labelKey) : "";
|
||||
return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
|
||||
};
|
||||
// Only the logged days go into the sr-only list; the empty-tinted cells
|
||||
// Only the logged days go into the data table; the empty-tinted cells
|
||||
// are already covered by the aggregate summary on the SVG, so a rotor
|
||||
// user hears the days that carry a value rather than a wall of "no entry".
|
||||
const loggedCells = cells.filter((cell) => cell.cell !== null);
|
||||
@@ -259,9 +223,7 @@ export function MoodHeatmap({
|
||||
// natural, square, left-aligned grid rather than CSS-stretching a few
|
||||
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
|
||||
className={stretch ? "block max-w-full" : "block"}
|
||||
onMouseLeave={() =>
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null))
|
||||
}
|
||||
{...heatmapDay.svgProps}
|
||||
>
|
||||
{monthMarkers.map((m, i) => (
|
||||
<text
|
||||
@@ -292,113 +254,51 @@ export function MoodHeatmap({
|
||||
),
|
||||
)}
|
||||
|
||||
{cells.map((cell) => {
|
||||
const buildText = (): string => describeCell(cell);
|
||||
return (
|
||||
{cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the data table below the grid, not
|
||||
// through the rects: a cell subtree nested under the SVG's
|
||||
// `role="img"` is pruned from the a11y tree, and per-cell tab
|
||||
// stops would flood the keyboard order on a year-long grid.
|
||||
// The rects stay a pure pointer affordance.
|
||||
{...heatmapDay.cellProps({
|
||||
dateKey: cell.dateKey,
|
||||
hasEntry: cell.cell !== null,
|
||||
describe: () => describeCell(cell),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{cells
|
||||
.filter((cell) => cell.dateKey === heatmapDay.openKey)
|
||||
.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
className="cursor-pointer"
|
||||
data-day={cell.dateKey}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the visually-hidden day list below
|
||||
// the grid, not through the rects: a cell subtree nested
|
||||
// under the SVG's `role="img"` is pruned from the a11y tree,
|
||||
// and per-cell tab stops would flood the keyboard order on a
|
||||
// year-long grid. The rects stay a pure pointer affordance.
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.cell !== null && isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.cell === null) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
key={`open-${cell.dateKey}`}
|
||||
data-slot="heatmap-open-day"
|
||||
x={labelWidth + cell.col * step - 1.5}
|
||||
y={headerHeight + cell.row * step - 1.5}
|
||||
width={cellSize + 3}
|
||||
height={cellSize + 3}
|
||||
rx={3}
|
||||
pointerEvents="none"
|
||||
{...OPEN_DAY_CELL}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
|
||||
carries an aggregate `role="img"` summary; this list is the
|
||||
keyboard/screen-reader path to the same granular values the pointer
|
||||
tooltip shows, without adding a tab stop per cell. */}
|
||||
{loggedCells.length > 0 && (
|
||||
<ul className="sr-only" data-slot="mood-heatmap-day-list">
|
||||
{loggedCells.map((cell) => (
|
||||
<li key={cell.dateKey}>{describeCell(cell)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
data-slot="mood-heatmap-tooltip"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
left:
|
||||
typeof window !== "undefined"
|
||||
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
|
||||
: tooltip.x + 10,
|
||||
top: tooltip.y - 30,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="heatmap-open-day"
|
||||
onClick={() => {
|
||||
const day = tooltip.day;
|
||||
setTooltip(null);
|
||||
if (day) openDay(day, { trigger: containerRef.current });
|
||||
}}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.openDay")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{heatmapDay.tooltipNode}
|
||||
|
||||
<div
|
||||
className="text-muted-foreground mt-2 flex flex-wrap items-center gap-3 text-xs"
|
||||
@@ -428,6 +328,31 @@ export function MoodHeatmap({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{loggedCells.length > 0 ? (
|
||||
<>
|
||||
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
|
||||
{/* The keyboard and screen-reader way to each logged day's value,
|
||||
and to the day itself. */}
|
||||
<ChartDataTable
|
||||
points={loggedCells.map((cell) => ({
|
||||
date: cell.dateKey,
|
||||
timestamp: dayAnchor(cell.dateKey),
|
||||
score: cell.cell?.score,
|
||||
}))}
|
||||
columns={[{ key: "score", label: t("charts.moodScore") }]}
|
||||
formatValue={(value) => {
|
||||
const labelKey = moodLabelKeyForScore(Math.round(value));
|
||||
return labelKey
|
||||
? `${value.toFixed(1)} · ${t(labelKey)}`
|
||||
: value.toFixed(1);
|
||||
}}
|
||||
formatDate={(date) => formatDay(dateOnlyKey(date))}
|
||||
bucket="day"
|
||||
metricLabel={t("insights.mood.heatmapTitle")}
|
||||
dayLinks
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,17 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
dayAnchor,
|
||||
useChartDayLinks,
|
||||
} from "@/components/day/chart-day-links";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { ChartDataTable } from "./chart-data-table";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
|
||||
/**
|
||||
@@ -36,6 +43,13 @@ interface NutrientDailyBarChartProps {
|
||||
valueLabel: string;
|
||||
/** Optional dashed reference line value (EFSA target / ceiling). */
|
||||
referenceValue?: number | null;
|
||||
/**
|
||||
* v1.42 — each bar opens its day, through the doors every day-linked
|
||||
* chart has (click, the tooltip's button on touch, the dashed line, the
|
||||
* row of dots, the data table). A day logged as nothing has no bar to
|
||||
* open.
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
export function NutrientDailyBarChart({
|
||||
@@ -43,6 +57,7 @@ export function NutrientDailyBarChart({
|
||||
unit,
|
||||
valueLabel,
|
||||
referenceValue,
|
||||
dayLinks = false,
|
||||
}: NutrientDailyBarChartProps) {
|
||||
const fmt = useFormatters();
|
||||
const points = useMemo(
|
||||
@@ -60,71 +75,125 @@ export function NutrientDailyBarChart({
|
||||
const animate = !prefersReducedMotion();
|
||||
const barColor = "var(--info)";
|
||||
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: dayLinks,
|
||||
days: points.map((p) => (p.amount > 0 ? p.day : null)),
|
||||
focusFor: (index) => {
|
||||
const point = points[index];
|
||||
return point
|
||||
? { label: valueLabel, value: fmt.integer(point.amount), unit }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const logged = points.filter((p) => p.amount > 0);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
<>
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
/>
|
||||
{referenceValue != null ? (
|
||||
<ReferenceLine
|
||||
y={referenceValue}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { day: string; amount: number } }>
|
||||
| undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: valueLabel,
|
||||
value: `${point.amount} ${unit}`,
|
||||
color: barColor,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
/>
|
||||
{referenceValue != null ? (
|
||||
<ReferenceLine
|
||||
y={referenceValue}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
) : null}
|
||||
{chartDays.openIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
x={points[chartDays.openIndex]?.day}
|
||||
{...OPEN_DAY_LINE}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { day: string; amount: number } }>
|
||||
| undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: valueLabel,
|
||||
value: `${point.amount} ${unit}`,
|
||||
color: barColor,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill={barColor}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points.map((p) => ({ timestamp: dayAnchor(p.day) }))}
|
||||
axis="band"
|
||||
mark="bar"
|
||||
// The y axis (44) on the left, the margin (12) on the right.
|
||||
insetLeft={44}
|
||||
insetRight={12}
|
||||
/>
|
||||
{chartDays.active && logged.length > 0 ? (
|
||||
<ChartDataTable
|
||||
points={logged.map((p) => ({
|
||||
date: p.day,
|
||||
timestamp: dayAnchor(p.day),
|
||||
amount: p.amount,
|
||||
}))}
|
||||
columns={[{ key: "amount", label: valueLabel }]}
|
||||
unit={unit}
|
||||
formatValue={(value) => fmt.integer(value)}
|
||||
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
|
||||
bucket="day"
|
||||
metricLabel={valueLabel}
|
||||
dayLinks
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill={barColor}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,20 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import { ChartDataTable } from "../charts/chart-data-table";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
OPEN_DAY_LINE,
|
||||
dayAnchor,
|
||||
useChartDayLinks,
|
||||
} from "../day/chart-day-links";
|
||||
import { dateKeyOfInstant } from "../day/day-url";
|
||||
import { formatMetricValue } from "./format-value";
|
||||
import type { CustomMetricEntryDto } from "./types";
|
||||
|
||||
@@ -48,11 +59,14 @@ interface ChartPoint {
|
||||
|
||||
export function CustomMetricChart({
|
||||
entries,
|
||||
name,
|
||||
unit,
|
||||
targetLow,
|
||||
targetHigh,
|
||||
decimals,
|
||||
}: {
|
||||
/** The metric's name: the day's focus label and the data table's caption. */
|
||||
name: string;
|
||||
/** Entries for this metric, any order (sorted internally). */
|
||||
entries: CustomMetricEntryDto[];
|
||||
unit: string;
|
||||
@@ -95,6 +109,25 @@ export function CustomMetricChart({
|
||||
return [min - pad, max + pad];
|
||||
}, [points, targetLow, targetHigh]);
|
||||
|
||||
// v1.42 — each value opens its day (the calendar day of its instant in
|
||||
// the record's zone) through the doors every day-linked chart has.
|
||||
const timeZone = useDisplayTimezone();
|
||||
const pointDays = points.map((point) =>
|
||||
dateKeyOfInstant(point.timestamp, timeZone),
|
||||
);
|
||||
const chartDays = useChartDayLinks({
|
||||
enabled: true,
|
||||
days: pointDays,
|
||||
focusFor: (index) => {
|
||||
const point = points[index];
|
||||
return point
|
||||
? { label: name, value: formatMetricValue(point.value, decimals), unit }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const openPoint =
|
||||
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
|
||||
@@ -121,94 +154,130 @@ export function CustomMetricChart({
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
|
||||
/>
|
||||
{targetLow != null && targetHigh != null ? (
|
||||
<ReferenceArea
|
||||
y1={targetLow}
|
||||
y2={targetHigh}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
<div
|
||||
data-slot="chart-plot"
|
||||
{...chartDays.plotProps}
|
||||
className={chartDays.plotClassName}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
onClick={chartDays.onChartClick}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
) : null}
|
||||
{targetLow != null ? (
|
||||
<ReferenceLine
|
||||
y={targetLow}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{targetHigh != null ? (
|
||||
<ReferenceLine
|
||||
y={targetHigh}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) =>
|
||||
fmt.dateShortSmart(new Date(ts))
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("customMetrics.chart.valueLabel"),
|
||||
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
|
||||
/>
|
||||
{targetLow != null && targetHigh != null ? (
|
||||
<ReferenceArea
|
||||
y1={targetLow}
|
||||
y2={targetHigh}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
{targetLow != null ? (
|
||||
<ReferenceLine
|
||||
y={targetLow}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{targetHigh != null ? (
|
||||
<ReferenceLine
|
||||
y={targetHigh}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{openPoint ? (
|
||||
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
|
||||
) : null}
|
||||
<Tooltip
|
||||
{...chartDays.tooltipProps}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("customMetrics.chart.valueLabel"),
|
||||
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
rows={rows}
|
||||
action={chartDays.tooltipAction(points.indexOf(point))}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartDayFooter
|
||||
links={chartDays}
|
||||
points={points}
|
||||
axis="time"
|
||||
// The y axis (44) on the left, the margin (12) on the right.
|
||||
insetLeft={44}
|
||||
insetRight={12}
|
||||
/>
|
||||
<ChartDataTable
|
||||
points={points.map((point, index) => ({
|
||||
date: pointDays[index]!,
|
||||
timestamp: dayAnchor(pointDays[index]!),
|
||||
value: point.value,
|
||||
}))}
|
||||
columns={[{ key: "value", label: name }]}
|
||||
unit={unit}
|
||||
formatValue={(value) => formatMetricValue(value, decimals)}
|
||||
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
|
||||
bucket="day"
|
||||
metricLabel={name}
|
||||
dayLinks
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -287,6 +287,7 @@ export function CustomMetricDetail({
|
||||
<CardContent>
|
||||
<CustomMetricChart
|
||||
entries={entries}
|
||||
name={metric?.name ?? ""}
|
||||
unit={metric?.unit ?? ""}
|
||||
targetLow={metric?.targetLow ?? null}
|
||||
targetHigh={metric?.targetHigh ?? null}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
|
||||
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") opens the day. Two steps, so a scroll that
|
||||
* grazes the chart never opens a sheet;
|
||||
* - 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 put and takes taps. */
|
||||
tooltipProps: {
|
||||
trigger: "click" | "hover";
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
};
|
||||
/** 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 },
|
||||
}
|
||||
: { 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`);
|
||||
}
|
||||
@@ -119,19 +119,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 +184,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 +214,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 +222,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"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 button every chart's tooltip carries) opens it;
|
||||
* a tap outside or Escape clears it;
|
||||
* - 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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