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/*` 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..96582d693 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( @@ -87,6 +102,7 @@ function Chart() { heightPct: 0, label: zoneLabel, textColor: undefined, + labelInside: !besidePlot, }, ], POINTS.length - 1, @@ -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..f90bebf1a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -55,7 +55,6 @@ import { pointIndexXAxis, resolveAnnotationPositions, resolveVerticalMarkerPositions, - xTickLayout, } from "./health-chart/series-math"; import type { HealthChartProps } from "./health-chart/types"; import { @@ -212,6 +211,7 @@ export function HealthChart({ onVisibleStats, }); + const [containerWidth, setContainerWidth] = useState(null); const overlays = useHealthChartOverlays({ showBands, showTrend, @@ -224,13 +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); + axisValueFormatters(yDomain, fmt, yTicks); const { primaryType, chartAriaLabel, dataTableColumns } = useHealthChartLabels({ @@ -415,11 +415,9 @@ export function HealthChart({ chartData, formatDate: tzFmt.date, viewportWidth, - layout: xTickLayout( - containerWidth, - yAxisWidth, - overlays.rightRoom, - ), + containerWidth, + yAxisWidth, + rightRoom: overlays.rightRoom, })} /> , t: Translate, + besidePlot = true, ) { return ( showTrend && @@ -258,7 +262,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..d1f1481b1 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,36 +316,71 @@ 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; +} + +/** 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 - * explicit tick positions (`computeTickPositions`). + * explicit tick positions (`computeTickGrid`), and a yearly grid is labelled + * with the year alone. */ 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, dataKey: "pointIndex", @@ -329,11 +389,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/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/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. */ 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); 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, + ); + }); +});