mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
17 files changed
+598
-61
No files matched your search
+22
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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 (
|
||||
<ComposedChart
|
||||
width={720}
|
||||
width={width}
|
||||
height={260}
|
||||
data={POINTS}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 8 + rightLabelRoom([label, zoneLabel]),
|
||||
right: 8 + rightRoom,
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
}}
|
||||
@@ -59,7 +74,7 @@ function Chart() {
|
||||
dataKey="pointIndex"
|
||||
domain={[0, POINTS.length - 1]}
|
||||
tick={{ fontSize: 11 }}
|
||||
ticks={computeTickPositions(POINTS, 1440)}
|
||||
ticks={ticks}
|
||||
tickFormatter={(value: number) =>
|
||||
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}
|
||||
/>
|
||||
<Line dataKey="BLOOD_PRESSURE_SYS" isAnimationActive={false} dot />
|
||||
{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,
|
||||
)}
|
||||
</ComposedChart>
|
||||
);
|
||||
@@ -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(<Chart width={PHONE_WIDTH} />);
|
||||
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(<Chart width={PHONE_WIDTH} />);
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number | null>(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<number | null>(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,
|
||||
})}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -453,6 +451,7 @@ export function HealthChart({
|
||||
colors,
|
||||
personalBaselines,
|
||||
t,
|
||||
overlays.besidePlot,
|
||||
)}
|
||||
<Tooltip
|
||||
filterNull={false}
|
||||
|
||||
@@ -65,7 +65,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: zone.labelInside ? "insideTopRight" : "right",
|
||||
offset: BASELINE_LABEL_GAP,
|
||||
fill: zone.textColor ?? zone.color,
|
||||
fontSize: BASELINE_LABEL_FONT_SIZE,
|
||||
@@ -231,7 +233,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 +242,7 @@ export function baselineLayers(
|
||||
colors: string[],
|
||||
personalBaselines: Map<string, number>,
|
||||
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,
|
||||
|
||||
@@ -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<number>,
|
||||
) {
|
||||
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>,
|
||||
): 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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<VisibleValueBand[]>(() => {
|
||||
if (!showBands || !valueBands?.length || !yDomain) return [];
|
||||
@@ -99,6 +106,7 @@ export function useHealthChartOverlays({
|
||||
);
|
||||
}, [showBands, valueBands, yDomain]);
|
||||
|
||||
const besidePlot = labelsBesidePlot(plotWidth);
|
||||
const visibleTargetZones = useMemo<VisibleTargetZone[]>(() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<CalendarStep> = [
|
||||
{ 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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Exclude<AggregateGrain, "raw">, number> = {
|
||||
daily: 1,
|
||||
weekly: 7,
|
||||
monthly: 28,
|
||||
};
|
||||
|
||||
/** The live grain that matches a rollup tier's buckets. */
|
||||
const TIER_GRAIN: Record<string, Exclude<AggregateGrain, "raw">> = {
|
||||
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<Awaited<ReturnType<typeof readTieredRollupSeries>>>,
|
||||
ctx: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
from: Date;
|
||||
priorityJson: unknown;
|
||||
opts: { timeZone?: string };
|
||||
},
|
||||
): Promise<DailySeriesRow[]> {
|
||||
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<Exclude<AggregateGrain, "raw">, string> = {
|
||||
daily: "day",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user