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);