From 467d5dcb48d2caad86d24e00f9e6b7b0a3e6c6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 07:42:27 +0100 Subject: [PATCH 1/4] fix(charts): give the plot its width back on a phone 1.46 moved the average's and the target zones' labels beside the plot, in a right margin sized to the longest label. On a phone that margin took about a third of the plot's width, and the legend, wrapping into the narrower space, took more of its height: a pulse chart drew at 160 x 66 px instead of 232 x 108. Below 520 px the labels sit inside the plot again, the average's at the top left as before and a zone's at the top right of its band, and the plot keeps no room beside it. The x ticks assumed an 80 px date label. A 90-day chart's month starts sit about 78 px apart on a phone, so the month grid never fit and the axis showed one date. The label width now comes from a date in the reader's format (10/01/2026 draws at 58 px). A yearly grid is labelled with the year alone, narrow enough for one tick a year, and with the year it marks when its point falls in the December before. The y axis shows the decimals its tick step needs: 57, 58, 59 bpm instead of 57.0, 58.0, 59.0. --- .../health-chart-axes.browser.test.tsx | 64 +++++++++++-- .../health-chart-axis-format.test.ts | 77 ++++++++++++++++ src/components/charts/health-chart.tsx | 23 ++++- .../charts/health-chart/chart-layers.tsx | 13 ++- .../charts/health-chart/series-math.ts | 91 ++++++++++++++----- .../charts/__tests__/x-axis-density.test.ts | 30 ++++++ src/lib/charts/x-axis-density.ts | 51 +++++++++-- 7 files changed, 304 insertions(+), 45 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-axis-format.test.ts diff --git a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx index 00257dad3..289218ea8 100644 --- a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx +++ b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx @@ -10,13 +10,15 @@ import { rightLabelRoom, targetZoneLayers, } from "../health-chart/chart-layers"; +import { labelsBesidePlot, xTickLayout } from "../health-chart/series-math"; /** * The value chart's axes and its baseline label, drawn by Recharts with the * props HealthChart hands it: the y ticks are round and evenly spaced and all * of them drawn, the x ticks fall on the calendar grid, and the "your * average" and target zone labels sit whole beside the plot, covering no - * reading. + * reading. On a phone the plot keeps no room beside it: the labels are drawn + * whole inside it, and a 90-day chart still gets its month grid. */ const DAY = 86_400_000; @@ -35,7 +37,20 @@ const t = (key: string) => const label = "Your average"; const zoneLabel = "Systolic target"; -function Chart() { +const PHONE_WIDTH = 324; +// HealthChart's default y axis width. +const Y_AXIS_WIDTH = 76; + +function Chart({ width = 720 }: { width?: number }) { + const besidePlot = labelsBesidePlot(width); + const rightRoom = besidePlot ? rightLabelRoom([label, zoneLabel]) : 0; + const ticks = besidePlot + ? computeTickPositions(POINTS, 1440) + : computeTickPositions( + POINTS, + 390, + xTickLayout(width, Y_AXIS_WIDTH, rightRoom, "12/31/2000"), + ); const padded = computePaddedYDomain( POINTS.map((p) => p.BLOOD_PRESSURE_SYS), ["BLOOD_PRESSURE_SYS"], @@ -43,12 +58,12 @@ function Chart() { const y = niceYAxis(padded); return ( new Date(POINTS[Math.round(value)]!.timestamp) .toISOString() @@ -71,7 +86,7 @@ function Chart() { tick={{ fontSize: 11 }} domain={y.domain} ticks={y.ticks} - width={44} + width={Y_AXIS_WIDTH} /> {targetZoneLayers( @@ -90,6 +105,7 @@ function Chart() { }, ], POINTS.length - 1, + besidePlot, )} {baselineLayers( true, @@ -97,6 +113,7 @@ function Chart() { ["#888"], new Map([["BLOOD_PRESSURE_SYS", 121]]), t, + besidePlot, )} ); @@ -182,4 +199,39 @@ describe("value chart axes", () => { expect(overlaps).toBe(false); } }); + + it("on a phone keeps no room beside the plot and draws both labels whole inside it", async () => { + const screen = await render(); + await settle(); + const root = screen.container; + const plot = root + .querySelector(".recharts-cartesian-grid")! + .getBoundingClientRect(); + const svg = root + .querySelector("svg.recharts-surface")! + .getBoundingClientRect(); + // The plot reaches the chart's right margin (8 px) and nothing more. + expect(svg.right - plot.right).toBeLessThanOrEqual(8 + 10 + 1); + for (const text of [label, zoneLabel]) { + const node = [...root.querySelectorAll(".recharts-label")].find( + (n) => n.textContent === text, + )!; + expect(node).toBeDefined(); + const box = node.getBoundingClientRect(); + expect(box.left).toBeGreaterThanOrEqual(plot.left); + expect(box.right).toBeLessThanOrEqual(plot.right + 1); + } + }); + + it("on a phone labels a 90-day chart on the month grid, three dates", async () => { + const screen = await render(); + await settle(); + // September 1 and October 1 have no reading, so their ticks take the + // nearest day that has one. + expect(tickTexts(screen.container, "xAxis")).toEqual([ + "08-01", + "08-31", + "09-30", + ]); + }); }); diff --git a/src/components/charts/__tests__/health-chart-axis-format.test.ts b/src/components/charts/__tests__/health-chart-axis-format.test.ts new file mode 100644 index 000000000..c13810d7b --- /dev/null +++ b/src/components/charts/__tests__/health-chart-axis-format.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + axisValueFormatters, + gridYear, + labelsBesidePlot, + xTickLayout, +} from "../health-chart/series-math"; + +/** + * The value axis shows the decimals its tick step needs and no more, a phone + * keeps the chart's labels inside the plot, and an x label is as wide as a + * date in the reader's format. + */ + +const fmt = { + number: (value: number, digits: number) => value.toFixed(digits), + integer: (value: number) => String(Math.round(value)), +}; + +describe("axis value format", () => { + it("writes a step of one as whole numbers, not 57.0, 58.0", () => { + const { formatAxisValue, useDecimalAxis } = axisValueFormatters( + [57, 62], + fmt, + [57, 58, 59, 60, 61, 62], + ); + expect(useDecimalAxis).toBe(false); + expect(formatAxisValue(58)).toBe("58"); + }); + + it("keeps one decimal for a step of 0.5 or 0.2, two for 0.05", () => { + expect( + axisValueFormatters([82, 84], fmt, [82, 82.5, 83]).formatAxisValue(82.5), + ).toBe("82.5"); + expect( + axisValueFormatters([82, 83], fmt, [82, 82.2, 82.4]).formatAxisValue( + 82.2, + ), + ).toBe("82.2"); + expect( + axisValueFormatters([1, 1.2], fmt, [1, 1.05, 1.1]).formatAxisValue(1.05), + ).toBe("1.05"); + }); + + it("without ticks, keeps one decimal while the domain spans under six units", () => { + expect(axisValueFormatters([82, 85], fmt).useDecimalAxis).toBe(true); + expect(axisValueFormatters([80, 90], fmt).useDecimalAxis).toBe(false); + }); +}); + +describe("labels beside the plot", () => { + it("are kept on a tablet or wider and dropped into the plot on a phone", () => { + expect(labelsBesidePlot(324)).toBe(false); + expect(labelsBesidePlot(519)).toBe(false); + expect(labelsBesidePlot(520)).toBe(true); + expect(labelsBesidePlot(720)).toBe(true); + }); +}); + +describe("x label width", () => { + it("follows the length of a date in the reader's format", () => { + const short = xTickLayout(324, 76, 0, "12/31/2000")!; + const long = xTickLayout(324, 76, 0, "2000. 12. 31.")!; + expect(short.labelWidth).toBeLessThan(70); + expect(long.labelWidth).toBeGreaterThan(short.labelWidth); + expect(short.plotWidth).toBe(324 - 16 - 76 - 20); + }); +}); + +describe("yearly grid labels", () => { + it("name the year the tick marks, also when it sits on a December point", () => { + expect(gridYear(Date.UTC(2025, 0, 1, 12))).toBe(2025); + expect(gridYear(Date.UTC(2024, 11, 18, 12))).toBe(2025); + expect(gridYear(Date.UTC(2025, 1, 10, 12))).toBe(2025); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c7c953124..57b2b53ad 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -51,6 +51,7 @@ import { axisValueFormatters, dayFocusFor, getTypeLabel, + labelsBesidePlot, makeDayKeyFormatter, pointIndexXAxis, resolveAnnotationPositions, @@ -66,6 +67,9 @@ import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; +/** A date with a two-digit day and month: the widest an x label gets. */ +const X_LABEL_SAMPLE_DATE = new Date(Date.UTC(2000, 11, 31, 12)); + /** * A metric over time: the line, the overlays a person can switch on (the * 7-day average, the trend, the target range, the prior period), reference @@ -230,7 +234,10 @@ export function HealthChart({ const [containerWidth, setContainerWidth] = useState(null); const { useDecimalAxis, formatAxisValue, formatTooltipValue } = - axisValueFormatters(yDomain, fmt); + axisValueFormatters(yDomain, fmt, yTicks); + // Until the container is measured, the viewport stands in for its width. + const besidePlot = labelsBesidePlot(containerWidth ?? viewportWidth); + const rightRoom = besidePlot ? overlays.rightRoom : 0; const { primaryType, chartAriaLabel, dataTableColumns } = useHealthChartLabels({ @@ -398,7 +405,7 @@ export function HealthChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, - right: 8 + overlays.rightRoom, + right: 8 + rightRoom, bottom: 8, left: 8, }} @@ -418,7 +425,8 @@ export function HealthChart({ layout: xTickLayout( containerWidth, yAxisWidth, - overlays.rightRoom, + rightRoom, + tzFmt.date(X_LABEL_SAMPLE_DATE), ), })} /> @@ -443,7 +451,11 @@ export function HealthChart({ : undefined } /> - {targetZoneLayers(visibleTargetZones, maxPointIndex)} + {targetZoneLayers( + visibleTargetZones, + maxPointIndex, + besidePlot, + )} {annotationLayers(annotationPositions, viewportWidth)} {verticalMarkerLayers(verticalMarkerPositions, yDomain)} {openDayLayers(openDayIndex, openDayPoint, types, colors)} @@ -453,6 +465,7 @@ export function HealthChart({ colors, personalBaselines, t, + besidePlot, )} ) : null} {/* The points the chart just drew, as a table, read from the same diff --git a/src/components/charts/health-chart/chart-layers.tsx b/src/components/charts/health-chart/chart-layers.tsx index ca23ddeb3..9a8a56c59 100644 --- a/src/components/charts/health-chart/chart-layers.tsx +++ b/src/components/charts/health-chart/chart-layers.tsx @@ -28,6 +28,7 @@ type Translate = (key: string) => string; export function targetZoneLayers( zones: VisibleTargetZone[], maxPointIndex: number, + besidePlot = true, ) { return [ zones.map((zone) => ( @@ -65,7 +66,9 @@ export function targetZoneLayers( zone.label ? { value: zone.label, - position: "right", + // On a narrow chart, inside the zone at its top right: the + // plot keeps no room beside it there (`labelsBesidePlot`). + position: besidePlot ? "right" : "insideTopRight", offset: BASELINE_LABEL_GAP, fill: zone.textColor ?? zone.color, fontSize: BASELINE_LABEL_FONT_SIZE, @@ -231,7 +234,8 @@ export function rightLabelRoom( * The personal baseline per type, only while the trend overlay is shown (an * always-on mean line was rejected). Only the first type carries the label, * so a multi-type chart does not paint it twice; it is drawn to the right of - * the plot, in the room `rightLabelRoom` keeps there. + * the plot, in the room `rightLabelRoom` keeps there, or inside the plot on a + * chart too narrow to keep that room. */ export function baselineLayers( showTrend: boolean, @@ -239,6 +243,7 @@ export function baselineLayers( colors: string[], personalBaselines: Map, t: Translate, + besidePlot = true, ) { return ( showTrend && @@ -258,7 +263,9 @@ export function baselineLayers( i === 0 ? { value: t("charts.personalBaseline"), - position: "right", + // Inside the plot at the top left on a narrow chart, as + // before the room beside it existed. + position: besidePlot ? "right" : "insideTopLeft", offset: BASELINE_LABEL_GAP, fill: "var(--muted-foreground)", fontSize: BASELINE_LABEL_FONT_SIZE, diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts index 6ce3eeba5..9f1a115e9 100644 --- a/src/components/charts/health-chart/series-math.ts +++ b/src/components/charts/health-chart/series-math.ts @@ -7,10 +7,7 @@ import { chartPointDayKey } from "@/components/day/chart-day"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; import { translateOrNull } from "@/lib/i18n/translate-code"; -import { - computeTickPositions, - type TickLayout, -} from "@/lib/charts/x-axis-density"; +import { computeTickGrid, type TickLayout } from "@/lib/charts/x-axis-density"; import type { ChartDataPoint } from "./types"; @@ -229,9 +226,11 @@ export function resolveAnnotationPositions( } /** - * The value axis's and the tooltip's number formats. One decimal on the axis - * while the visible domain spans under 6 units, so two gridlines never round - * to the same integer (82.8 vs 83.5 kg). + * The value axis's and the tooltip's number formats. The axis shows as many + * decimals as its tick step needs: none for a step of 1 or more (57, 58, 59 + * bpm rather than 57.0, 58.0, 59.0), one for 0.5 or 0.2, two for 0.05. Without + * ticks, one decimal while the visible domain spans under 6 units, so two + * gridlines never round to the same integer (82.8 vs 83.5 kg). */ export function axisValueFormatters( yDomain: [number, number] | undefined, @@ -239,17 +238,43 @@ export function axisValueFormatters( number: (value: number, digits: number) => string; integer: (value: number) => string; }, + yTicks?: ReadonlyArray, ) { - const span = yDomain ? yDomain[1] - yDomain[0] : null; - const useDecimalAxis = span !== null && span < 6; + const digits = axisDecimals(yDomain, yTicks); return { - useDecimalAxis, + useDecimalAxis: digits > 0, formatAxisValue: (value: number) => - useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value), + digits > 0 ? fmt.number(value, digits) : fmt.integer(value), formatTooltipValue: (value: number) => fmt.number(value, 1), }; } +function axisDecimals( + yDomain: [number, number] | undefined, + yTicks?: ReadonlyArray, +): number { + if (yTicks && yTicks.length >= 2) { + const step = Math.abs(yTicks[1]! - yTicks[0]!); + if (step >= 1 || step === 0) return 0; + return Math.min(2, Math.ceil(-Math.log10(step) - 1e-9)); + } + const span = yDomain ? yDomain[1] - yDomain[0] : null; + return span !== null && span < 6 ? 1 : 0; +} + +/** + * Below this chart width the labels beside the plot (the average's, the target + * zones') move into it, as they were drawn before 1.46. On a phone the room + * beside the plot took about a third of its width, and the legend, wrapping + * into the narrower space, took more of its height. + */ +export const LABELS_BESIDE_PLOT_MIN_WIDTH = 520; + +/** Whether a chart this wide keeps its labels beside the plot. */ +export function labelsBesidePlot(width: number): boolean { + return width >= LABELS_BESIDE_PLOT_MIN_WIDTH; +} + /** * What the day shows at its top when opened from a point: the chart's name * and the point's value as the tooltip reads it. A pressure or a pulse reads @@ -291,24 +316,45 @@ export function dayFocusFor( * chart margins, the y axis, the right-hand label room and the axis padding) * and the width a date label takes, with room between two. Null until the * container has been measured; the ticks then fall back to the count cap. + * + * The label's width comes from a date in the reader's own format: at the + * axis's 11 px a digit or a separator is under 6 px wide (10/01/2026 draws at + * 58 px). A fixed 80 px kept the month grid off a 90-day chart on a phone, + * whose month starts sit about 78 px apart, and left it one date. */ export function xTickLayout( containerWidth: number | null, yAxisWidth: number, rightRoom: number, -): { plotWidth: number; labelWidth: number } | undefined { + sampleLabel?: string, +): TickLayout | undefined { if (!containerWidth) return undefined; const plotWidth = containerWidth - 16 - yAxisWidth - rightRoom - 20; if (plotWidth <= 0) return undefined; - // A day-month-year date at the axis's 11 px, with a gap of its own size - // between two labels so the row reads as separate dates. - return { plotWidth, labelWidth: 64 + 16 }; + const textWidth = sampleLabel ? Math.ceil(sampleLabel.length * 5.8) : 58; + return { + plotWidth, + labelWidth: textWidth + 8, + yearLabelWidth: Math.ceil(4 * 5.8) + 8, + }; +} + +/** + * The year a tick on the yearly grid stands for. The tick sits on the point + * nearest January 1, which may be one from the December before when the + * series has a gap there; it still marks the new year. + */ +export function gridYear(timestamp: number): number { + const date = new Date(timestamp); + const year = date.getUTCFullYear(); + return date.getUTCMonth() >= 6 ? year + 1 : year; } /** * The x axis's props: one point per index, labelled with its date. Recharts * ignores `interval` on a numeric axis, so the calendar grid arrives as - * explicit tick positions (`computeTickPositions`). + * explicit tick positions (`computeTickGrid`), and a yearly grid is labelled + * with the year alone. */ export function pointIndexXAxis({ chartData, @@ -321,6 +367,7 @@ export function pointIndexXAxis({ viewportWidth: number; layout: TickLayout | undefined; }) { + const grid = computeTickGrid(chartData ?? [], viewportWidth, layout); return { type: "number" as const, dataKey: "pointIndex", @@ -329,11 +376,13 @@ export function pointIndexXAxis({ tick: { fontSize: 11, fill: "var(--muted-foreground)" }, tickLine: false, axisLine: false, - tickFormatter: (value: number) => - formatDate( - new Date(chartData?.[Math.round(value)]?.timestamp ?? Date.now()), - ), - ticks: computeTickPositions(chartData ?? [], viewportWidth, layout), + tickFormatter: (value: number) => { + const timestamp = chartData?.[Math.round(value)]?.timestamp ?? Date.now(); + return grid.yearly + ? String(gridYear(timestamp)) + : formatDate(new Date(timestamp)); + }, + ticks: grid.ticks, padding: { left: 10, right: 10 }, tickMargin: 10, }; diff --git a/src/lib/charts/__tests__/x-axis-density.test.ts b/src/lib/charts/__tests__/x-axis-density.test.ts index fac5dd0d6..b905519e4 100644 --- a/src/lib/charts/__tests__/x-axis-density.test.ts +++ b/src/lib/charts/__tests__/x-axis-density.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { chooseTickInterval, + computeTickGrid, computeTickPositions, resolveTargetTickCount, } from "../x-axis-density"; @@ -196,6 +197,35 @@ describe("computeTickPositions — ticks on a calendar grid", () => { } }); + it("labels a long history on a phone by the year, one tick a year", () => { + // Four and a half years in monthly points on a 212 px plot: a full date + // fits only every other year, the year alone fits every year. + const months = Array.from({ length: 55 }, (_, i) => ({ + timestamp: Date.UTC(2022, 3 + i, 1, 12), + })); + const layout = { plotWidth: 212, labelWidth: 66, yearLabelWidth: 32 }; + const grid = computeTickGrid(months, 390, layout); + expect(grid.yearly).toBe(true); + expect(dates(months, grid.ticks)).toEqual([ + "2023-01-01", + "2024-01-01", + "2025-01-01", + "2026-01-01", + ]); + // Without the narrow year label, the same plot holds a date every two + // years only. + const full = computeTickGrid(months, 390, { + plotWidth: 212, + labelWidth: 66, + }); + expect(full.ticks.length).toBe(2); + }); + + it("does not call a month grid yearly", () => { + const year = daily("2025-10-23", 365); + expect(computeTickGrid(year, 1440).yearly).toBe(false); + }); + it("keeps under the width's cap, with no duplicate or out-of-range index", () => { for (const [width, cap] of [ [393, 5], diff --git a/src/lib/charts/x-axis-density.ts b/src/lib/charts/x-axis-density.ts index 204c2005f..63e257933 100644 --- a/src/lib/charts/x-axis-density.ts +++ b/src/lib/charts/x-axis-density.ts @@ -169,7 +169,7 @@ export function getViewportWidth(): number { * 90-day blood-pressure chart with a few missed days the labels fell 18, 14, * 13, 17 and 23 days apart, and the forced last label crowded its neighbour. * So the step is a calendar one (a day, two days, a week, two weeks, a month, - * a quarter, half a year, a year), the smallest that keeps the labels under + * a quarter, half a year, a year, two years), the smallest that keeps the labels under * the width's cap, and each tick is the point on that grid's date: Mondays * for the week steps (an even-week grid for two), the first of the month for * the month steps. A grid date with no point of its own takes the nearest @@ -184,9 +184,22 @@ export function computeTickPositions( viewportWidth: number, layout?: TickLayout, ): number[] { + return computeTickGrid(chartData, viewportWidth, layout).ticks; +} + +/** + * The tick positions and whether they fall on a grid of whole years, where a + * label needs only the year: "2024" rather than "01/01/2024". A year label is + * also narrower, so on a phone every year of a long history gets one. + */ +export function computeTickGrid( + chartData: ReadonlyArray<{ timestamp: number } | unknown>, + viewportWidth: number, + layout?: TickLayout, +): { ticks: number[]; yearly: boolean } { const length = chartData?.length ?? 0; - if (length <= 0) return []; - if (length === 1) return [0]; + if (length <= 0) return { ticks: [], yearly: false }; + if (length === 1) return { ticks: [0], yearly: false }; const days = chartData.map((point) => Math.floor( @@ -194,7 +207,10 @@ export function computeTickPositions( ), ); if (days.some((day) => !Number.isFinite(day))) { - return Array.from({ length }, (_, index) => index); + return { + ticks: Array.from({ length }, (_, index) => index), + yearly: false, + }; } const first = days[0]!; const last = days[length - 1]!; @@ -224,28 +240,42 @@ export function computeTickPositions( // the days, are spaced evenly, so two grid dates can sit closer than their // step where the series is dense. Recharts dropped such a colliding label // itself, which left the grid with holes. Without a width, the count cap. - const fits = (ticks: number[]) => { + const fits = (ticks: number[], step: CalendarStep) => { if (!layout) return ticks.length <= maxTicks; const perIndex = layout.plotWidth / Math.max(1, length - 1); + const labelWidth = + isYearly(step) && layout.yearLabelWidth + ? layout.yearLabelWidth + : layout.labelWidth; return ticks.every( - (tick, i) => - i === 0 || (tick - ticks[i - 1]!) * perIndex >= layout.labelWidth, + (tick, i) => i === 0 || (tick - ticks[i - 1]!) * perIndex >= labelWidth, ); }; for (const step of CALENDAR_STEPS) { const count = Math.floor((last - first) / step.approxDays) + 1; if (!layout && count > maxTicks) continue; const ticks = ticksFor(step); - if (ticks.length > 0 && fits(ticks)) return ticks; + if (ticks.length > 0 && fits(ticks, step)) { + return { ticks, yearly: isYearly(step) }; + } } - const widest = ticksFor(CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!); - return widest.length > 0 ? widest : [0]; + const widestStep = CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!; + const widest = ticksFor(widestStep); + return widest.length > 0 + ? { ticks: widest, yearly: isYearly(widestStep) } + : { ticks: [0], yearly: false }; +} + +function isYearly(step: CalendarStep): boolean { + return step.kind === "months" && step.size >= 12; } /** Where the ticks are drawn: the plot's width and a label's width, in px. */ export interface TickLayout { plotWidth: number; labelWidth: number; + /** A year-only label's width, for the yearly grid; else `labelWidth`. */ + yearLabelWidth?: number; } const DAY_MS = 86_400_000; @@ -271,6 +301,7 @@ const CALENDAR_STEPS: ReadonlyArray = [ { kind: "months", size: 3, approxDays: 91 }, { kind: "months", size: 6, approxDays: 182 }, { kind: "months", size: 12, approxDays: 365 }, + { kind: "months", size: 24, approxDays: 730 }, ]; /** The grid's dates (as day numbers) from `first` to `last`, both included. */ From 13860422cf82656f1b0519a17958445466358b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 07:46:50 +0100 Subject: [PATCH 2/4] fix(series): show a record's history older than the rollups on the All range A range longer than a year reads its series from the week or month rollups, which are kept for the last five years. When the rollups held any rows the read returned them alone, so on a record that goes back further the All range started where the rollups start: a weight history from 2015 charted from late 2021. The readings older than the first rollup day are folded live at the tier's grain and lead the series; the head ends where the first tier bucket begins, so no month is counted twice. One indexed lookup when there are none. --- src/lib/measurements/daily-series-read.ts | 84 +++++++++++++- src/lib/rollups/measurement-read-wmy.ts | 3 + .../series-all-range-history.test.ts | 108 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/integration/series-all-range-history.test.ts diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 702af8782..9b40b2ca5 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -104,7 +104,7 @@ export async function readDailySeries(opts: { }); } if (tiered && tiered.rows.length > 0) { - return tiered.rows; + return withLiveHead(tiered, { userId, type, from, priorityJson, opts }); } // Coverage miss at every tier — fall through to the daily path, which // folds the live table. The daily fallback still caps at `cap`; the @@ -146,6 +146,88 @@ export async function readDailySeries(opts: { return rows; } +/** The shortest bucket of a live grain, in days (a month is at least 28). */ +const GRAIN_MIN_DAYS: Record, number> = { + daily: 1, + weekly: 7, + monthly: 28, +}; + +/** The live grain that matches a rollup tier's buckets. */ +const TIER_GRAIN: Record> = { + DAY: "daily", + WEEK: "weekly", + MONTH: "monthly", +}; + +/** + * The tiered rollup rows, with the window's older history in front of them. + * + * Rollups are kept for the last five years (`ROLLUP_FOLD_WINDOW_MS`), so on + * a record that goes back further the tier read starts where they start, and + * the "All" range showed nothing before it: a weight history from 2015 + * charted from late 2021. The readings older than the first rollup day are + * folded live, at the tier's grain, and lead the series. One indexed lookup + * when there are none. + */ +async function withLiveHead( + tiered: NonNullable>>, + ctx: { + userId: string; + type: MeasurementType; + from: Date; + priorityJson: unknown; + opts: { timeZone?: string }; + }, +): Promise { + const { userId, type, from, priorityJson } = ctx; + const grain = TIER_GRAIN[tiered.granularity]; + // The first tier row's bucket holds the first rollup day; the head ends + // where that bucket starts, so no reading is counted in both. + const firstRowAt = new Date(tiered.rows[0]!.measuredAt); + const headEnd = new Date( + Math.min(tiered.firstDay.getTime(), firstRowAt.getTime() - 12 * 3_600_000), + ); + if (!grain || headEnd <= from) return tiered.rows; + const older = await prisma.measurement.findFirst({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: from, lt: headEnd }, + }, + select: { id: true }, + }); + if (!older) return tiered.rows; + const timeZone = ctx.opts.timeZone ?? (await resolveUserTimezone(userId)); + const head = await readLiveBuckets({ + userId, + type, + from, + to: new Date(headEnd.getTime() - 1), + // Enough buckets for the whole head (the grain's default cap is sized + // for a window, and the head of an "All" range can be years long). + cap: Math.max( + BUCKET_CAP[grain], + Math.ceil( + (headEnd.getTime() - from.getTime()) / + (GRAIN_MIN_DAYS[grain] * 86_400_000), + ) + 2, + ), + priorityJson, + grain, + timeZone, + }); + annotate({ + meta: { + tiered_series_live_head: head.length, + type, + granularity: tiered.granularity, + }, + }); + return [...head, ...tiered.rows]; +} + /** SQL `date_trunc` unit per aggregate grain (a closed map, never input). */ const TRUNC_UNIT: Record, string> = { daily: "day", diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 5dacae788..7ba0e5648 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -394,6 +394,8 @@ export async function readTieredRollupSeries(opts: { }): Promise<{ granularity: RollupGranularity; rows: TieredSeriesRow[]; + /** The first DAY bucket in the window: where the rollups' history starts. */ + firstDay: Date; } | null> { const { userId, type, from, to, priorityJson } = opts; // Tier selection keys off the window WIDTH; derive it the same way the @@ -454,6 +456,7 @@ export async function readTieredRollupSeries(opts: { }); return { granularity, + firstDay: first, rows: rows.map((r) => ({ type, // The chart's quantity at every tier: the average day of the bucket, diff --git a/tests/integration/series-all-range-history.test.ts b/tests/integration/series-all-range-history.test.ts new file mode 100644 index 000000000..c173948bb --- /dev/null +++ b/tests/integration/series-all-range-history.test.ts @@ -0,0 +1,108 @@ +/** + * The "All" range reaches a record's whole history. + * + * Rollups are kept for the last five years. A range longer than a year reads + * its series from the week or month rollups, and on a record that goes back + * further it used to start where the rollups start: a weight history from + * 2018 charted from five years ago. The older readings are folded live at + * the same grain and lead the series, without a month counted twice where + * the two meet. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { readDailySeries } from "@/lib/measurements/daily-series-read"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY = 86_400_000; + +beforeAll(async () => { + await truncateAllTables(prisma); +}); + +afterAll(async () => { + await truncateAllTables(prisma); +}); + +describe("the All range over a history older than the rollups", () => { + it("leads with the readings the rollups do not hold", async () => { + const user = await makeUser({ timezone: "Europe/Berlin" }); + const now = Date.now(); + // A weekly weight from eight years ago until now. + const weeks = Math.floor((8 * 365) / 7); + await prisma.measurement.createMany({ + data: Array.from({ length: weeks }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80 + (i % 10) / 10, + unit: "kg", + measuredAt: new Date(now - (weeks - i) * 7 * DAY), + source: "MANUAL" as const, + })), + }); + await recomputeUserRollups(user.id, { types: ["WEIGHT"] }); + const oldestRollup = await prisma.measurementRollup.aggregate({ + where: { userId: user.id, type: "WEIGHT", granularity: "DAY" }, + _min: { bucketStart: true }, + }); + // The setup holds what production holds: no rollup older than five years. + expect(oldestRollup._min.bucketStart!.getTime()).toBeGreaterThan( + now - 5 * 366 * DAY, + ); + + const rows = await readDailySeries({ + userId: user.id, + type: "WEIGHT", + from: new Date(now - 3650 * DAY), + to: new Date(now), + priorityJson: null, + }); + + const first = new Date(rows[0]!.measuredAt).getTime(); + expect(first).toBeLessThan(now - 7.5 * 365 * DAY); + const last = new Date(rows[rows.length - 1]!.measuredAt).getTime(); + expect(last).toBeGreaterThan(now - 62 * DAY); + + // Monthly buckets, in order, each month once. + const months = rows.map((row) => + new Date(row.measuredAt).toISOString().slice(0, 7), + ); + expect(new Set(months).size).toBe(months.length); + expect([...months].sort()).toEqual(months); + // Eight years of weekly readings fill about 96 months. + expect(months.length).toBeGreaterThanOrEqual(94); + }); + + it("reads the rollups alone when the history starts inside them", async () => { + const user = await makeUser({ timezone: "Europe/Berlin" }); + const now = Date.now(); + await prisma.measurement.createMany({ + data: Array.from({ length: 150 }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80, + unit: "kg", + measuredAt: new Date(now - (150 - i) * 7 * DAY), + source: "MANUAL" as const, + })), + }); + await recomputeUserRollups(user.id, { types: ["WEIGHT"] }); + const rows = await readDailySeries({ + userId: user.id, + type: "WEIGHT", + from: new Date(now - 3650 * DAY), + to: new Date(now), + priorityJson: null, + }); + const months = rows.map((row) => + new Date(row.measuredAt).toISOString().slice(0, 7), + ); + expect(new Set(months).size).toBe(months.length); + expect(new Date(rows[0]!.measuredAt).getTime()).toBeGreaterThan( + now - 151 * 7 * DAY - 31 * DAY, + ); + }); +}); From f4cb15d823958e445295a54e29cd5443e50c15d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 07:47:25 +0100 Subject: [PATCH 3/4] chore(release): v1.46.1 --- CHANGELOG.md | 23 ++++++++++++++++++++++- docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e28d48b1b..c1222db31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.46.1] — 2026-10-26 + +### Fixed + +- **Charts on a phone are their old size again.** 1.46 moved the labels + for your usual level and for a target zone out of the chart and beside + it. On a phone that room took about a third of the chart's width, and + the legend took more of its height. On a narrow screen the labels sit + inside the chart again, as they did before; a tablet or a wide window + keeps them beside it. +- **The date axis shows more than one date on a phone.** A 90-day chart + is labelled with the first of each month again, and the All range with + the years. +- **The value axis writes whole numbers when its steps are whole**: 57, + 58, 59 bpm rather than 57.0, 58.0, 59.0. +- **The All range shows your whole history.** Long ranges are read from + summaries the server keeps for the last five years, and readings older + than that did not appear: a weight history going back to 2015 started + in late 2021. The older readings are now read directly and lead the + chart. + ## [1.46.0] — 2026-10-25 ### Added @@ -99,7 +120,7 @@ fails, the app does not start and the log says why; a move cut short is run again from scratch on the next start. The old `pgdata` volume is left as it was, so going back is the previous image and two lines of the - compose file. On a real 600 MB instance the move took eleven seconds. + compose file. On a real 600 MB instance the move took 29 seconds. HealthLog keeps running on Postgres 16 for installs that bring their own database, and the tests run on both. (discussion #1282) - **A quicker, lighter start.** On an account with ten years of data the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1cd6e70ce..479a40268 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.46.0 + version: 1.46.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 2161c6999..7d6e510da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.46.0", + "version": "1.46.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 40404281b..ad0284d5c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.46.0"; + /* @sw-version-fallback */ "v1.46.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 444ff66086d762c2e0768155f4b2a68a0397927e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 07:59:58 +0100 Subject: [PATCH 4/4] refactor(charts): decide in the overlays hook where a chart's labels go The narrow-chart rule lived in HealthChart's body and grew it past its size baseline. The overlays hook, which already sizes the room beside the plot, now takes the chart's width, keeps no room on a narrow chart and marks each target zone whose label sits inside it; the x axis helper works out its own tick layout. The tiered series unit test mocks the lookup for readings older than the rollups and covers the head the read puts in front of them. --- .../health-chart-axes.browser.test.tsx | 2 +- src/components/charts/health-chart.tsx | 32 ++++-------- .../charts/health-chart/chart-layers.tsx | 3 +- .../charts/health-chart/series-math.ts | 17 ++++++- src/components/charts/health-chart/types.ts | 2 + .../health-chart/use-health-chart-overlays.ts | 25 ++++++--- .../daily-series-read-tiered.test.ts | 51 +++++++++++++++++++ 7 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx index 289218ea8..96582d693 100644 --- a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx +++ b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx @@ -102,10 +102,10 @@ function Chart({ width = 720 }: { width?: number }) { heightPct: 0, label: zoneLabel, textColor: undefined, + labelInside: !besidePlot, }, ], POINTS.length - 1, - besidePlot, )} {baselineLayers( true, diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 57b2b53ad..f90bebf1a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -51,12 +51,10 @@ import { axisValueFormatters, dayFocusFor, getTypeLabel, - labelsBesidePlot, makeDayKeyFormatter, pointIndexXAxis, resolveAnnotationPositions, resolveVerticalMarkerPositions, - xTickLayout, } from "./health-chart/series-math"; import type { HealthChartProps } from "./health-chart/types"; import { @@ -67,9 +65,6 @@ import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; -/** A date with a two-digit day and month: the widest an x label gets. */ -const X_LABEL_SAMPLE_DATE = new Date(Date.UTC(2000, 11, 31, 12)); - /** * A metric over time: the line, the overlays a person can switch on (the * 7-day average, the trend, the target range, the prior period), reference @@ -216,6 +211,7 @@ export function HealthChart({ onVisibleStats, }); + const [containerWidth, setContainerWidth] = useState(null); const overlays = useHealthChartOverlays({ showBands, showTrend, @@ -228,16 +224,13 @@ export function HealthChart({ valueMode, t, fmt, + plotWidth: containerWidth ?? viewportWidth, }); const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } = overlays; - const [containerWidth, setContainerWidth] = useState(null); const { useDecimalAxis, formatAxisValue, formatTooltipValue } = axisValueFormatters(yDomain, fmt, yTicks); - // Until the container is measured, the viewport stands in for its width. - const besidePlot = labelsBesidePlot(containerWidth ?? viewportWidth); - const rightRoom = besidePlot ? overlays.rightRoom : 0; const { primaryType, chartAriaLabel, dataTableColumns } = useHealthChartLabels({ @@ -405,7 +398,7 @@ export function HealthChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, - right: 8 + rightRoom, + right: 8 + overlays.rightRoom, bottom: 8, left: 8, }} @@ -422,12 +415,9 @@ export function HealthChart({ chartData, formatDate: tzFmt.date, viewportWidth, - layout: xTickLayout( - containerWidth, - yAxisWidth, - rightRoom, - tzFmt.date(X_LABEL_SAMPLE_DATE), - ), + containerWidth, + yAxisWidth, + rightRoom: overlays.rightRoom, })} /> - {targetZoneLayers( - visibleTargetZones, - maxPointIndex, - besidePlot, - )} + {targetZoneLayers(visibleTargetZones, maxPointIndex)} {annotationLayers(annotationPositions, viewportWidth)} {verticalMarkerLayers(verticalMarkerPositions, yDomain)} {openDayLayers(openDayIndex, openDayPoint, types, colors)} @@ -465,7 +451,7 @@ export function HealthChart({ colors, personalBaselines, t, - besidePlot, + overlays.besidePlot, )} ) : null} {/* The points the chart just drew, as a table, read from the same diff --git a/src/components/charts/health-chart/chart-layers.tsx b/src/components/charts/health-chart/chart-layers.tsx index 9a8a56c59..dada67197 100644 --- a/src/components/charts/health-chart/chart-layers.tsx +++ b/src/components/charts/health-chart/chart-layers.tsx @@ -28,7 +28,6 @@ type Translate = (key: string) => string; export function targetZoneLayers( zones: VisibleTargetZone[], maxPointIndex: number, - besidePlot = true, ) { return [ zones.map((zone) => ( @@ -68,7 +67,7 @@ export function targetZoneLayers( value: zone.label, // On a narrow chart, inside the zone at its top right: the // plot keeps no room beside it there (`labelsBesidePlot`). - position: besidePlot ? "right" : "insideTopRight", + position: zone.labelInside ? "insideTopRight" : "right", offset: BASELINE_LABEL_GAP, fill: zone.textColor ?? zone.color, fontSize: BASELINE_LABEL_FONT_SIZE, diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts index 9f1a115e9..d1f1481b1 100644 --- a/src/components/charts/health-chart/series-math.ts +++ b/src/components/charts/health-chart/series-math.ts @@ -350,6 +350,9 @@ export function gridYear(timestamp: number): number { return date.getUTCMonth() >= 6 ? year + 1 : year; } +/** A date with a two-digit day and month: the widest an x label gets. */ +const X_LABEL_SAMPLE_DATE = new Date(Date.UTC(2000, 11, 31, 12)); + /** * The x axis's props: one point per index, labelled with its date. Recharts * ignores `interval` on a numeric axis, so the calendar grid arrives as @@ -360,13 +363,23 @@ export function pointIndexXAxis({ chartData, formatDate, viewportWidth, - layout, + containerWidth, + yAxisWidth, + rightRoom, }: { chartData: ChartDataPoint[] | undefined; formatDate: (date: Date) => string; viewportWidth: number; - layout: TickLayout | undefined; + containerWidth: number | null; + yAxisWidth: number; + rightRoom: number; }) { + const layout = xTickLayout( + containerWidth, + yAxisWidth, + rightRoom, + formatDate(X_LABEL_SAMPLE_DATE), + ); const grid = computeTickGrid(chartData ?? [], viewportWidth, layout); return { type: "number" as const, diff --git a/src/components/charts/health-chart/types.ts b/src/components/charts/health-chart/types.ts index db6fef62e..e35602289 100644 --- a/src/components/charts/health-chart/types.ts +++ b/src/components/charts/health-chart/types.ts @@ -183,4 +183,6 @@ export interface VisibleTargetZone { label: string | undefined; textColor: string | undefined; lineOpacity: number; + /** The label sits inside the zone: the chart is too narrow for room beside it. */ + labelInside?: boolean; } diff --git a/src/components/charts/health-chart/use-health-chart-overlays.ts b/src/components/charts/health-chart/use-health-chart-overlays.ts index 5e156965a..478cc6d7e 100644 --- a/src/components/charts/health-chart/use-health-chart-overlays.ts +++ b/src/components/charts/health-chart/use-health-chart-overlays.ts @@ -11,7 +11,11 @@ import type { useFormatters } from "@/lib/i18n/context"; import type { ChartDataTableColumn } from "../chart-data-table"; -import { computePersonalBaseline, getTypeLabel } from "./series-math"; +import { + computePersonalBaseline, + getTypeLabel, + labelsBesidePlot, +} from "./series-math"; import type { ChartDataPoint, ChartTargetZone, @@ -62,6 +66,7 @@ export function useHealthChartOverlays({ valueMode, t, fmt, + plotWidth, }: { showBands: boolean; showTrend: boolean; @@ -74,6 +79,8 @@ export function useHealthChartOverlays({ valueMode: "raw" | "bmi"; t: Translate; fmt: Formatters; + /** The chart's width, which decides whether its labels sit beside the plot. */ + plotWidth: number; }) { const visibleBands = useMemo(() => { if (!showBands || !valueBands?.length || !yDomain) return []; @@ -99,6 +106,7 @@ export function useHealthChartOverlays({ ); }, [showBands, valueBands, yDomain]); + const besidePlot = labelsBesidePlot(plotWidth); const visibleTargetZones = useMemo(() => { if (!showBands || !targetZones?.length || !yDomain) return []; const span = yDomain[1] - yDomain[0]; @@ -116,10 +124,11 @@ export function useHealthChartOverlays({ label: zone.label ?? undefined, textColor: zone.textColor ?? undefined, lineOpacity: zone.lineOpacity ?? 0.42, + labelInside: !besidePlot, }); } return zones; - }, [showBands, targetZones, yDomain]); + }, [showBands, targetZones, yDomain, besidePlot]); const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -226,10 +235,14 @@ export function useHealthChartOverlays({ visibleTargetZones, trendInfo, personalBaselines, - rightRoom: rightLabelRoom([ - baselineLabel, - ...visibleTargetZones.map((zone) => zone.label), - ]), + besidePlot, + // No room beside the plot on a narrow chart: its labels sit inside it. + rightRoom: besidePlot + ? rightLabelRoom([ + baselineLabel, + ...visibleTargetZones.map((zone) => zone.label), + ]) + : 0, }; } diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index e80ed6c9b..97250e23f 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ findMany: vi.fn(), + findFirst: vi.fn(), queryRaw: vi.fn(), readTieredRollupSeries: vi.fn(), })); @@ -19,6 +20,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/db", () => ({ prisma: { measurementRollup: { findMany: mocks.findMany }, + measurement: { findFirst: mocks.findFirst }, $queryRaw: mocks.queryRaw, }, })); @@ -33,6 +35,9 @@ const DAY_MS = 86_400_000; beforeEach(() => { mocks.findMany.mockReset(); + mocks.findFirst.mockReset(); + // No reading older than the rollups unless a case says so. + mocks.findFirst.mockResolvedValue(null); mocks.queryRaw.mockReset(); mocks.readTieredRollupSeries.mockReset(); // The daily path folds live, one row per local day. @@ -75,6 +80,7 @@ describe("readDailySeries — long-range tier step-up", () => { mocks.readTieredRollupSeries.mockResolvedValueOnce({ granularity: "MONTH", rows: tieredRows, + firstDay: new Date("2017-01-01T00:00:00.000Z"), }); const result = await readDailySeries({ @@ -98,6 +104,51 @@ describe("readDailySeries — long-range tier step-up", () => { expect(mocks.queryRaw).not.toHaveBeenCalled(); }); + it("folds the readings older than the rollups live and puts them first", async () => { + const to = new Date("2026-06-03T12:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockResolvedValueOnce({ + granularity: "MONTH", + rows: [ + { + type: "WEIGHT", + value: 79, + measuredAt: "2021-07-01T12:00:00.000Z", + count: 30, + }, + ], + firstDay: new Date("2021-07-04T00:00:00.000Z"), + }); + mocks.findFirst.mockResolvedValueOnce({ id: "older" }); + mocks.queryRaw.mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: new Date("2018-03-01T00:00:00.000Z"), + avg: 84, + cnt: 4, + min_value: 83, + max_value: 85, + }, + ]); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + timeZone: TZ, + }); + + // The head reads before the first tier bucket (July 2021) only. + expect(mocks.findFirst.mock.calls[0][0].where.measuredAt.lt).toEqual( + new Date("2021-07-01T00:00:00.000Z"), + ); + expect(result).toHaveLength(2); + expect(result[0].value).toBe(84); + expect(result[1].measuredAt).toBe("2021-07-01T12:00:00.000Z"); + }); + it("falls through to the daily path on a tiered coverage miss (no silent empty)", async () => { const to = new Date("2026-06-21T00:00:00.000Z"); const from = new Date(to.getTime() - 3650 * DAY_MS);