Merge pull request #840 from MBombeck/release/v1.37.29

release: v1.37.29
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-26 19:18:37 +02:00
commit 898a5a95f3
22 files changed
+404 -145

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.37.29] — 2026-08-26
The chart range selector said points and meant days. Someone who weighs in twice a week picked "7 pts", saw two, and reasonably concluded the chart was broken.
### Fixed
- The range tabs now say what they do: 7d / 30d / 90d / All, in every locale. The selector has always fetched a calendar-day window; only the labels claimed a point count. Anyone measuring less than daily saw fewer points than the tab promised and had no way to know the tab was the lying part.
- The "All" view no longer serves monthly means under a weekly-average caption. The server picked the aggregation tier from the requested window, and "All" always requests ten years, so every record came back in monthly buckets no matter how short its history, while the chart captioned the series from the real span. A four-month record showed four points labelled "Weekly avg". The tier now follows the span the data actually covers, and a test pins the server tier against the caption ladder for every span up to ten years so the two cannot drift apart again.
- The same tab no longer means two different things depending on how the chart mounted. On the dashboard's preloaded data the old code showed "the last N readings" reaching arbitrarily far back; on a directly opened chart it showed a day window. Both paths now filter to the same calendar-day window, which also keeps comparison-overlay shadow data from leaking into the visible range on sparse records.
- The mood chart windows by days like every other chart. It used to slice the last N logged days out of the whole history, so its "7" could span weeks while the weight chart's "7" meant a week.
- The aggregation chip in the chart header reads the same grain decision the chart and its data table use, instead of re-deriving it separately.
## [1.37.28] — 2026-08-24
Eleven parts of a shared record were selectable and invisible. Ticking one and sending the link showed the recipient nothing, while the same link's PDF download carried it.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.28
version: 1.37.29
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+7 -1
View File
@@ -60,11 +60,17 @@ const A11Y_WORKOUT_ID = "a11y-workout";
const A11Y_CONVERSATION_ID = "a11y-conversation";
const A11Y_DOCUMENT_ID = "a11y-document";
// Ten daily rows ending half a day before now. The chart range tabs are
// calendar-day windows anchored on now (v1.37.29), so absolute-dated rows
// age out of the default window — the weight sub-page would render its
// empty state and the `.recharts-wrapper` painted gate would never fire.
const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({
id: `a11y-measurement-${index}`,
type: "WEIGHT",
value: 78.5 + (index % 3) - 1,
measuredAt: `2026-07-${String(20 - index).padStart(2, "0")}T08:00:00.000Z`,
measuredAt: new Date(
Date.now() - 12 * 3_600_000 - index * 86_400_000,
).toISOString(),
source: "MANUAL",
notes: null,
}));
+2 -2
View File
@@ -8,8 +8,8 @@ import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
*
* Two assertions per chart visible in the dashboard's initial paint:
*
* 1. Header layout integrity — the range tabs (`7 pts / 30 pts /
* 90 pts / All`) and the cog dropdown sit on a SINGLE horizontal
* 1. Header layout integrity — the range tabs (`7d / 30d /
* 90d / All`) and the cog dropdown sit on a SINGLE horizontal
* row inside the chart card. Pre-fix the bucket-aggregation chip
* ("Weekly avg" / "Monthly avg") + comparison caption pushed the
* tabs onto a second row on Pixel 5 (and 3-4 rows on Galaxy Fold
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Keine Daten im gewählten Zeitraum",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
"points7Label": "7 Pkt",
"points7Title": "Letzte 7 Messpunkte",
"points30Label": "30 Pkt",
"points30Title": "Letzte 30 Messpunkte",
"points90Label": "90 Pkt",
"points90Title": "Letzte 90 Messpunkte",
"pointsAllLabel": "Alle",
"pointsAllTitle": "Alle verfügbaren Messpunkte",
"days7Label": "7T",
"days7Title": "Letzte 7 Tage",
"days30Label": "30T",
"days30Title": "Letzte 30 Tage",
"days90Label": "90T",
"days90Title": "Letzte 90 Tage",
"daysAllLabel": "Alle",
"daysAllTitle": "Gesamter Zeitraum",
"systolic": "Systolisch",
"diastolic": "Diastolisch",
"bodyWater": "Körperwasser",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "No data in selected time range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
"points7Label": "7 pts",
"points7Title": "Last 7 data points",
"points30Label": "30 pts",
"points30Title": "Last 30 data points",
"points90Label": "90 pts",
"points90Title": "Last 90 data points",
"pointsAllLabel": "All",
"pointsAllTitle": "All available data points",
"days7Label": "7d",
"days7Title": "Last 7 days",
"days30Label": "30d",
"days30Title": "Last 30 days",
"days90Label": "90d",
"days90Title": "Last 90 days",
"daysAllLabel": "All",
"daysAllTitle": "All time",
"systolic": "Systolic",
"diastolic": "Diastolic",
"bodyWater": "Body water",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Sin datos en el periodo seleccionado",
"bucketWeekly": "Media semanal",
"bucketMonthly": "Media mensual",
"points7Label": "7 ptos",
"points7Title": "Últimos 7 puntos",
"points30Label": "30 ptos",
"points30Title": "Últimos 30 puntos",
"points90Label": "90 ptos",
"points90Title": "Últimos 90 puntos",
"pointsAllLabel": "Todos",
"pointsAllTitle": "Todos los puntos disponibles",
"days7Label": "7 d",
"days7Title": "Últimos 7 días",
"days30Label": "30 d",
"days30Title": "Últimos 30 días",
"days90Label": "90 d",
"days90Title": "Últimos 90 días",
"daysAllLabel": "Todos",
"daysAllTitle": "Todo el período",
"systolic": "Sistólica",
"diastolic": "Diastólica",
"bodyWater": "Agua corporal",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Aucune donnée sur la période sélectionnée",
"bucketWeekly": "Moyenne hebdomadaire",
"bucketMonthly": "Moyenne mensuelle",
"points7Label": "7 pts",
"points7Title": "Derniers 7 points",
"points30Label": "30 pts",
"points30Title": "Derniers 30 points",
"points90Label": "90 pts",
"points90Title": "Derniers 90 points",
"pointsAllLabel": "Tous",
"pointsAllTitle": "Tous les points disponibles",
"days7Label": "7 j",
"days7Title": "7 derniers jours",
"days30Label": "30 j",
"days30Title": "30 derniers jours",
"days90Label": "90 j",
"days90Title": "90 derniers jours",
"daysAllLabel": "Tous",
"daysAllTitle": "Toute la période",
"systolic": "Systolique",
"diastolic": "Diastolique",
"bodyWater": "Eau corporelle",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Nessun dato nel periodo selezionato",
"bucketWeekly": "Media settimanale",
"bucketMonthly": "Media mensile",
"points7Label": "7 pti",
"points7Title": "Ultimi 7 punti",
"points30Label": "30 pti",
"points30Title": "Ultimi 30 punti",
"points90Label": "90 pti",
"points90Title": "Ultimi 90 punti",
"pointsAllLabel": "Tutti",
"pointsAllTitle": "Tutti i punti disponibili",
"days7Label": "7 g",
"days7Title": "Ultimi 7 giorni",
"days30Label": "30 g",
"days30Title": "Ultimi 30 giorni",
"days90Label": "90 g",
"days90Title": "Ultimi 90 giorni",
"daysAllLabel": "Tutti",
"daysAllTitle": "Tutto il periodo",
"systolic": "Sistolica",
"diastolic": "Diastolica",
"bodyWater": "Acqua corporea",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Brak danych w wybranym okresie",
"bucketWeekly": "Średnia tygodniowa",
"bucketMonthly": "Średnia miesięczna",
"points7Label": "7 pkt",
"points7Title": "Ostatnie 7 punktów",
"points30Label": "30 pkt",
"points30Title": "Ostatnie 30 punktów",
"points90Label": "90 pkt",
"points90Title": "Ostatnie 90 punktów",
"pointsAllLabel": "Wszystkie",
"pointsAllTitle": "Wszystkie dostępne punkty",
"days7Label": "7 dni",
"days7Title": "Ostatnie 7 dni",
"days30Label": "30 dni",
"days30Title": "Ostatnie 30 dni",
"days90Label": "90 dni",
"days90Title": "Ostatnie 90 dni",
"daysAllLabel": "Wszystkie",
"daysAllTitle": "Cały okres",
"systolic": "Skurczowe",
"diastolic": "Rozkurczowe",
"bodyWater": "Woda w organizmie",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.28",
"version": "1.37.29",
"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
View File
@@ -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.37.28";
/* @sw-version-fallback */ "v1.37.29";
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/*`
@@ -11,11 +11,14 @@ import deMessages from "../../../../messages/de.json";
function buildData(): unknown[] {
const out: Array<{ date: string; timestamp: number; PULSE: number }> = [];
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
// Ten daily points ending half a day before now — the range tabs are
// calendar-day windows anchored on now (v1.37.29), so an absolute-dated
// fixture would age out of the default window and render the empty state.
const base = Date.now() - 12 * 3_600_000;
for (let i = 0; i < 10; i++) {
out.push({
date: `2026-01-${String(i + 1).padStart(2, "0")}`,
timestamp: base + i * 86_400_000,
date: `p${i}`,
timestamp: base - (9 - i) * 86_400_000,
PULSE: 60 + i,
});
}
@@ -11,12 +11,18 @@ import enMessages from "../../../../messages/en.json";
* table's rows can ever diverge, that is the failure this catches.
*/
/** `spacingDays` apart, so a fixed point count can span a chosen range. */
/**
* `spacingDays` apart, ENDING half a day before now. The range tabs are
* calendar-day windows anchored on now (v1.37.29), so an absolute-dated
* fixture would age out of the window and exercise the empty state
* instead of the table. The half-day offset keeps every point clear of
* the exact window-boundary instant.
*/
function series(count: number, spacingDays = 1) {
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
const base = Date.now() - 12 * 3_600_000;
return Array.from({ length: count }, (_, i) => ({
date: `p${i}`,
timestamp: base + i * spacingDays * 86_400_000,
timestamp: base - (count - 1 - i) * spacingDays * 86_400_000,
PULSE: 60 + i,
}));
}
@@ -101,17 +107,23 @@ describe("<HealthChart> — data table", () => {
});
it("honours the visible range rather than the whole fetched series", async () => {
// 40 points fetched, the default range tab draws the newest 30.
// 40 daily points fetched, the default range tab windows to the last
// 30 calendar days — the 30 points inside the window survive, the 10
// older ones do not.
const html = await renderChart(series(40), { showDataTable: true });
expect(rowCount(html)).toBe(30);
expect(html).toContain("Data points (30)");
});
it("captions a bucketed range as an average instead of implying daily readings", async () => {
// 30 points four days apart span 116 days, so the chart buckets to ISO
// weeks and several points share a bucket. The rows are weekly means and
// the caption has to say so.
const html = await renderChart(series(30, 4), { showDataTable: true });
// 30 points four days apart span 116 days on the All range, so the
// chart buckets to ISO weeks and several points share a bucket. The
// rows are weekly means and the caption has to say so. (The 7/30/90
// tabs are day windows now, so only "All" can span past 90 days.)
const html = await renderChart(series(30, 4), {
showDataTable: true,
windowOverride: "allTime",
});
expect(html).toContain("One row per week of");
expect(html).toContain("the average of that week");
expect(html).not.toContain("One row per day of");
@@ -28,6 +28,18 @@ function buildData(
}));
}
/**
* ISO instant `agoDays` before now (noon-offset). The range tabs are
* calendar-day windows anchored on now (v1.37.29), so an absolute-dated
* fixture would age out of the default window and always paint the
* no-data card instead of the sparse states under test.
*/
function daysAgo(agoDays: number): string {
return new Date(
Date.now() - 12 * 3_600_000 - agoDays * 86_400_000,
).toISOString();
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
@@ -71,8 +83,8 @@ describe("<HealthChart> — sparse-data render contract", () => {
it("renders the chart (not a withholding card) with a sparse caption for two distinct days", async () => {
const html = await renderChart(
buildData([
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
{ measuredAt: daysAgo(1), value: 70 },
{ measuredAt: daysAgo(0), value: 72 },
]),
);
@@ -87,7 +99,7 @@ describe("<HealthChart> — sparse-data render contract", () => {
it("renders the single marker + sparse caption for one day instead of a bare hint", async () => {
const html = await renderChart(
buildData([{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }]),
buildData([{ measuredAt: daysAgo(0), value: 70 }]),
);
// A single point still renders (Recharts paints the marker) and the
@@ -109,9 +121,9 @@ describe("<HealthChart> — sparse-data render contract", () => {
it("omits the sparse caption once three or more distinct days exist", async () => {
const html = await renderChart(
buildData([
{ measuredAt: "2026-05-18T09:00:00.000Z", value: 68 },
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
{ measuredAt: daysAgo(2), value: 68 },
{ measuredAt: daysAgo(1), value: 70 },
{ measuredAt: daysAgo(0), value: 72 },
]),
);
@@ -152,9 +152,12 @@ describe("<MoodChart> bucket chip", () => {
it("paints the weekly chip when the data spans > 90 days", async () => {
// Defer the import so the vi.mock above takes effect.
const { I18nProvider } = await import("@/lib/i18n/context");
// The 7/30/90 tabs are calendar-day windows anchored on now
// (v1.37.29), so only the All range can hold a span past 90 days —
// which is the state the weekly chip exists for.
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodChart />
<MoodChart windowOverride="allTime" />
</I18nProvider>,
);
// The chip text comes from `charts.bucketWeekly` ("Weekly avg").
@@ -23,6 +23,15 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
/**
* Day key `agoDays` before now. The range tabs are calendar-day windows
* anchored on now (v1.37.29), so absolute-dated entries would age out of
* the default window and both cases would collapse into the no-data copy.
*/
function dayKeyAgo(agoDays: number): string {
return new Date(Date.now() - agoDays * 86_400_000).toISOString().slice(0, 10);
}
describe("<MoodChart> — empty-state gate by raw count", () => {
beforeEach(() => {
vi.resetModules();
@@ -31,8 +40,8 @@ describe("<MoodChart> — empty-state gate by raw count", () => {
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
const data = {
entries: [
{ date: "2026-05-19", score: 4, samples: 25 },
{ date: "2026-05-20", score: 5, samples: 25 },
{ date: dayKeyAgo(1), score: 4, samples: 25 },
{ date: dayKeyAgo(0), score: 5, samples: 25 },
],
trend: null,
};
@@ -65,7 +74,7 @@ describe("<MoodChart> — empty-state gate by raw count", () => {
it("renders no-data copy when rawCount < 3", async () => {
const data = {
entries: [{ date: "2026-05-20", score: 4, samples: 1 }],
entries: [{ date: dayKeyAgo(0), score: 4, samples: 1 }],
trend: null,
};
+47 -30
View File
@@ -65,26 +65,33 @@ import {
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
// "the last 7 days", not "the last 7 readings". The labels said "points"
// until v1.37.29 while the fetch below always treated the value as a day
// window; a user who weighs in twice a week picked "7 pts" and saw two.
// The persisted preference field keeps its historical name `rangePoints`
// (it crosses the chart-overlay-prefs wire), but its value has always
// been days.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.points7Label",
points: 7,
titleKey: "charts.points7Title",
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.points30Label",
points: 30,
titleKey: "charts.points30Title",
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.points90Label",
points: 90,
titleKey: "charts.points90Title",
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.pointsAllLabel",
points: 0,
titleKey: "charts.pointsAllTitle",
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
@@ -738,8 +745,8 @@ export function HealthChart({
const totalDays = windowDays + compareShift;
const from = new Date(to.getTime() - totalDays * 86_400_000);
// Use ISO strings so the cache key is stable across the day; the
// server treats `to` as the upper bound and the chart truncates to
// `rangePoints` on the client.
// server treats `to` as the upper bound and the chart re-applies the
// same day window client-side (see `visibleSlice`).
return {
from: from.toISOString(),
to: to.toISOString(),
@@ -1124,9 +1131,23 @@ export function HealthChart({
// by it, and the data table below the chart captions its rows with it —
// a table that said "daily" over a weekly mean would be a lie, and
// recomputing the decision separately is how that lie starts.
//
// v1.37.29 — the slice is a CALENDAR-DAY window, matching what the
// range tabs promise and what the fetch window requests. The previous
// `slice(-rangePoints)` was a no-op on the self-fetched path (the fetch
// is already bounded to the window) but silently switched to
// "last N readings" on the dashboard's preloaded ~30-day batch slice —
// the same tab meant two different things depending on the mount path.
const visibleSlice = useMemo(() => {
if (!data?.length) return null;
const sliced = rangePoints > 0 ? data.slice(-rangePoints) : [...data];
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? data.filter((point) => point.timestamp >= cutoff)
: [...data];
// An empty window stays an empty slice (renders as "no data in this
// range") — returning null here would drop chartData back to the
// UNFILTERED series and quietly widen the window instead.
const rangeDays =
sliced.length < 2
? 0
@@ -1226,7 +1247,7 @@ export function HealthChart({
// v1.12.8 — chart-reactive metric statistics.
//
// Compute the per-type Min / Max / Median / Mean over the data currently
// visible under the active range tab — i.e. the `rangePoints`-sliced,
// visible under the active range tab — i.e. the day-window-filtered,
// bucketed `chartData` the chart already holds (no re-fetch). The result
// recomputes whenever the range tab changes the visible slice, so the
// `<MetricStatStrip>` a sub-page lifts the callback into always reflects the
@@ -1330,16 +1351,12 @@ export function HealthChart({
);
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.length) return "day";
const sliced = rangePoints > 0 ? data.slice(-rangePoints) : data;
if (sliced.length < 2) return "day";
const rangeDays = Math.round(
(sliced[sliced.length - 1].timestamp - sliced[0].timestamp) /
(24 * 60 * 60 * 1000),
);
return pickBucket(rangeDays);
}, [data, rangePoints]);
// v1.37.29 — the header chip reads the SAME grain decision `chartData`
// and the data table use. This was a second memo re-deriving the bucket
// from a `slice(-rangePoints)` of its own — exactly the "recomputing
// the decision separately" drift the visibleSlice doc-comment warns
// about, and the two had already diverged on the windowing semantics.
const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day";
const yDomain = useMemo<[number, number] | undefined>(() => {
if (!chartDataWithCompare?.length) return undefined;
@@ -1783,8 +1800,8 @@ export function HealthChart({
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
aria-pressed={rangePoints === r.points}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
@@ -1792,14 +1809,14 @@ export function HealthChart({
// visible-range stats memo recomputes off the new slice and
// the stat strip follows automatically.
setRangeHydrated(true);
setRangePoints(r.points);
setRangePoints(r.days);
// v1.30.1 M2 — persist the pick per chart, same model
// as the overlay toggles, so the range survives a
// remount instead of resetting to 30 d every visit.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
rangePoints: r.days,
});
}
}}
+13 -10
View File
@@ -3,22 +3,25 @@
*
* The Oura-style RecommendationCard (B5c) embeds a mini-chart that
* pins to the recommendation's `rationale.dataWindow`. This helper
* maps the rationale enum onto the per-points range used by the
* maps the rationale enum onto the calendar-day window used by the
* existing chart wrappers.
*
* last7days → 7 points
* last30days → 30 points
* last90days → 90 points
* allTime → 0 (HealthChart treats 0 as "no slice — show all")
* last7days → 7-day window
* last30days → 30-day window
* last90days → 90-day window
* allTime → 0 (HealthChart treats 0 as "no window — show all")
*
* The values mirror the existing `TIME_RANGES_KEYS` definition in
* `health-chart.tsx`; the helper is a pure function so unit tests
* pin the contract without depending on Recharts internals.
* pin the contract without depending on Recharts internals. The
* exported name keeps the historical `rangePoints` vocabulary because
* it feeds the chart state/preference field of that name (a persisted
* wire field) — the VALUE has always been calendar days.
*/
export type DataWindow = "last7days" | "last30days" | "last90days" | "allTime";
const POINTS_BY_WINDOW: Record<DataWindow, number> = {
const DAYS_BY_WINDOW: Record<DataWindow, number> = {
last7days: 7,
last30days: 30,
last90days: 90,
@@ -26,9 +29,9 @@ const POINTS_BY_WINDOW: Record<DataWindow, number> = {
};
/**
* Returns the per-points range value the chart wrapper expects for a
* given rationale dataWindow enum value.
* Returns the day-window value the chart wrapper expects for a given
* rationale dataWindow enum value.
*/
export function resolveMiniRangePoints(window: DataWindow): number {
return POINTS_BY_WINDOW[window];
return DAYS_BY_WINDOW[window];
}
+35 -18
View File
@@ -106,26 +106,31 @@ interface MoodChartProps {
// --- Constants ---
// The range tabs select a CALENDAR-DAY window ending now, matching
// `health-chart.tsx` (see the note there). Until v1.37.29 this chart
// sliced the last N LOGGED DAYS out of the whole history instead — the
// same "7" tab meant seven calendar days on a weight chart and seven
// logged days (possibly weeks apart) on the mood chart one card over.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.points7Label",
points: 7,
titleKey: "charts.points7Title",
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.points30Label",
points: 30,
titleKey: "charts.points30Title",
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.points90Label",
points: 90,
titleKey: "charts.points90Title",
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.pointsAllLabel",
points: 0,
titleKey: "charts.pointsAllTitle",
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
@@ -444,7 +449,13 @@ export function MoodChart({
}))
.sort((a, b) => a.timestamp - b.timestamp);
const sliced = rangePoints > 0 ? allPoints.slice(-rangePoints) : allPoints;
// v1.37.29 — a calendar-day window, not "the last N logged days":
// the tabs promise days, and the measurement charts window by days.
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? allPoints.filter((point) => point.timestamp >= cutoff)
: allPoints;
// v1.4.15 Fix 3: parity with health-chart's auto-bucketing. Other
// dashboard charts (BP, weight, pulse) auto-aggregate to weekly /
@@ -581,10 +592,16 @@ export function MoodChart({
// doesn't crash the chip render.
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.entries?.length) return "day";
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sorted = [...data.entries].sort((a, b) =>
a.date.localeCompare(b.date),
);
const sliced = rangePoints > 0 ? sorted.slice(-rangePoints) : sorted;
// v1.37.29 — the same calendar-day window `chartData` applies, so the
// chip describes the points actually painted.
const sliced =
rangePoints > 0
? sorted.filter((entry) => dayKeyToTimestamp(entry.date) >= cutoff)
: sorted;
if (sliced.length < 2) return "day";
const firstTs = dayKeyToTimestamp(sliced[0].date);
const lastTs = dayKeyToTimestamp(sliced[sliced.length - 1].date);
@@ -1079,19 +1096,19 @@ export function MoodChart({
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
aria-pressed={rangePoints === r.points}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.points);
setRangePoints(r.days);
// v1.30.1 M2 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
rangePoints: r.days,
});
}
}}
@@ -43,10 +43,12 @@ vi.mock("@/lib/db", () => ({
import {
aggregateWmyBuckets,
pickRollupGranularityForWindow,
readBestGranularityRollups,
readTieredRollupSeries,
type RollupBucketRow,
} from "../measurement-read-wmy";
import { pickBucket } from "@/lib/charts/bucket-time-series";
const { findMany } = mocks;
@@ -70,6 +72,12 @@ function bucket(
beforeEach(() => {
findMany.mockReset();
// Default to "no coverage" for any read a test did not queue explicitly —
// the v1.37.29 span-refinement probe in `readTieredRollupSeries` issues an
// extra read when a fixture's rows span less than the requested window,
// and an unqueued vi.fn() would resolve `undefined` and crash the reader
// instead of modelling an unminted tier.
findMany.mockResolvedValue([]);
mocks.userFindUnique.mockReset();
mocks.userFindUnique.mockResolvedValue({ sourcePriorityJson: null });
});
@@ -454,4 +462,120 @@ describe("readTieredRollupSeries", () => {
expect(result?.rows[0].minValue).toBe(78);
expect(result?.rows[0].maxValue).toBe(85);
});
// v1.37.29 — the tier keys off the ACTUAL data span, not the requested
// window width. The "All" tab always requests ~3650 days, so pre-fix a
// record whose history spans four months came back as four MONTH means
// while the chart captioned them from the real span — "weekly average"
// over monthly buckets.
describe("span refinement", () => {
it("re-reads at the tier the real span calls for (4 months of data on an All request → DAY)", async () => {
const months = [
bucket("2026-02-01T00:00:00.000Z"),
bucket("2026-03-01T00:00:00.000Z"),
bucket("2026-04-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
const days = [
bucket("2026-02-03T00:00:00.000Z"),
bucket("2026-03-14T00:00:00.000Z"),
bucket("2026-04-09T00:00:00.000Z"),
bucket("2026-05-28T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(days);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
// Span of the MONTH rows ≈ 89 days → the display ladder wants DAY,
// and the refinement jumps straight there (no WEEK stop-over).
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
expect(findMany.mock.calls[1][0].where.granularity).toBe("DAY");
expect(findMany).toHaveBeenCalledTimes(2);
expect(result?.granularity).toBe("DAY");
expect(result?.rows).toHaveLength(4);
expect(result?.rows[0].measuredAt).toBe("2026-02-03T00:00:00.000Z");
});
it("refines to WEEK for a span between one and two years", async () => {
const months = [
bucket("2025-01-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
const weeks = [
bucket("2024-12-30T00:00:00.000Z"),
bucket("2026-04-27T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(weeks);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK");
expect(result?.granularity).toBe("WEEK");
});
it("keeps the coarser rows when the finer tier has no coverage", async () => {
const months = [
bucket("2026-01-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce([]);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
expect(result?.granularity).toBe("MONTH");
expect(result?.rows).toHaveLength(2);
});
it("does not probe when the rows already span the requested tier", async () => {
const months = [
bucket("2018-01-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
expect(findMany).toHaveBeenCalledTimes(1);
expect(result?.granularity).toBe("MONTH");
});
});
});
// v1.37.29 — the server tier ladder and the client caption ladder
// (`pickBucket`) are DIFFERENT functions with different thresholds, and
// their divergence is exactly what shipped monthly means under a
// "weekly average" chip. The invariant that keeps the caption honest is
// directional: for any data span, the tier the server serves is never
// COARSER than the bucket the client captions — the client may fold
// finer rows up to its caption, but it cannot split coarser ones.
describe("server tier vs client caption ladder", () => {
const SERVER_COARSENESS = { DAY: 0, WEEK: 1, MONTH: 2, YEAR: 3 } as const;
const CLIENT_COARSENESS = { day: 0, week: 1, month: 2 } as const;
it("never serves coarser than the client captions, for every span up to ten years", () => {
for (let span = 1; span <= 3650; span++) {
const server = pickRollupGranularityForWindow(span);
const client = pickBucket(span);
expect(
SERVER_COARSENESS[server],
`span ${span}d: server ${server} vs client caption ${client}`,
).toBeLessThanOrEqual(CLIENT_COARSENESS[client]);
}
});
});
+53 -12
View File
@@ -303,20 +303,28 @@ export interface TieredSeriesRow {
}
/**
* Pick the rollup granularity that matches the chart's own client-side
* `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`):
* Pick the rollup granularity for a chart window of `windowDays`:
*
* - > 730 days → MONTH (the chart renders month points)
* - 366–730 → WEEK (the chart renders week points)
* - > 730 days → MONTH
* - 366–730 → WEEK
* - ≤ 365 → DAY (daily resolution; the DAY cap covers it)
*
* Mirroring the display tier means the server returns exactly the
* resolution the chart will paint — no finer (which would truncate at the
* DAY cap) and no coarser (which would drop visible detail). The finer
* fallback in `readTieredRollupSeries` still rescues a tenant whose
* coarse buckets the worker has not minted yet.
* This ladder must stay COMPATIBLE with the chart's client-side
* `pickBucket` (`src/lib/charts/bucket-time-series.ts`), which captions
* the rendered series from the visible data span (day ≤ 90, week 91–730,
* month > 730): for any span the tier chosen here is never COARSER than
* the caption the client will print, so a "Wochendurchschnitt" chip
* always sits over points the client actually folded into weeks. The
* ladders are not identical — this one may be finer (DAY rows for a
* 91–365 d span; the client folds them into the weeks it captions) —
* and a test pins the never-coarser direction across every span.
*
* The finer fallback in `readTieredRollupSeries` still rescues a tenant
* whose coarse buckets the worker has not minted yet.
*/
function pickRollupGranularityForWindow(windowDays: number): RollupGranularity {
export function pickRollupGranularityForWindow(
windowDays: number,
): RollupGranularity {
if (windowDays > 730) return "MONTH";
if (windowDays > 365) return "WEEK";
return "DAY";
@@ -393,8 +401,8 @@ export async function readTieredRollupSeries(opts: {
// drop detail the chart can render). Stop at the first tier with coverage.
const targetIdx = TIER_ORDER.indexOf(target);
for (let i = targetIdx; i >= 0; i--) {
const granularity = TIER_ORDER[i];
const rows = await readGranularity(
let granularity = TIER_ORDER[i];
let rows = await readGranularity(
userId,
type,
granularity,
@@ -403,6 +411,39 @@ export async function readTieredRollupSeries(opts: {
priority,
);
if (rows && rows.length > 0) {
// Refine the tier by the ACTUAL data span, not the requested window.
// The "Alle" tab always requests ~3650 days, so keying the tier off
// the request width alone handed EVERY account MONTH buckets — a
// record whose history spans four months came back as four monthly
// means while the client (which captions from the real span) labelled
// them "weekly average". Re-reading at the tier the real span calls
// for returns the resolution the chart will actually caption. A
// coverage miss on the finer tier keeps the coarser rows — finer
// tiers are the base tiers the workers mint first, so in practice
// this probe only fires when the refinement is genuinely available.
// The walk is strictly finer-only, so it terminates.
for (;;) {
const spanDays = Math.ceil(
(rows[rows.length - 1].bucketStart.getTime() -
rows[0].bucketStart.getTime()) /
86_400_000,
);
const refined = pickRollupGranularityForWindow(Math.max(1, spanDays));
if (TIER_ORDER.indexOf(refined) >= TIER_ORDER.indexOf(granularity)) {
break;
}
const finerRows = await readGranularity(
userId,
type,
refined,
from,
to,
priority,
);
if (!finerRows || finerRows.length === 0) break;
granularity = refined;
rows = finerRows;
}
const useSum = CUMULATIVE_HK_TYPES.has(type);
annotate({
action: { name: "measurement.list" },