diff --git a/.planning/round-v1438-marathon-kickoff.md b/.planning/round-v1438-marathon-kickoff.md new file mode 100644 index 000000000..ec6c3818d --- /dev/null +++ b/.planning/round-v1438-marathon-kickoff.md @@ -0,0 +1,122 @@ +# v1.4.38 Marathon — full backlog sweep + perf hotspots + +**Started:** 2026-05-17 (immediately after v1.4.37.2 deploy verify) +**Mandate:** Marc directive — "Ich möchte jetzt echt den Deckel drauf kriegen und nichts jetzt auf ein v1.4.39 Release mehr ausschieben, sondern ich möchte, dass das jetzt alles da drinnen ist." +**Versioning:** **v1.4.38** (patch — additive + fixes + perf, no breaking change). + +## Scope + +Everything in `.planning/round-v1438-backlog.md` PLUS the two perf hotspots +surfaced during v1.4.37.x verification: + +1. `/api/insights/comprehensive` cold-mount ~3.4 s — likely same shape + as the v1.4.37.2 slim-summaries bug (over-fetch then JS-aggregate) +2. `/api/dashboard/summary` cold-mount ~4.6 s — iOS aggregator, big + parallel queries on `measurements` without rollup-side aggregation + +## Default Picks (autonomous — no Marc-approval wait) + +- **i18n volle Lokalisierung:** Option A (T1 mandatory + T2 high-value + medical/health domain copy). Settings/Admin/Dev strings stay + EN-fallback for now. +- **Coach cascade tests:** both H4 + M-5 fixes applied. Grep-based gate + inventory test + SSR spy/CSR render replacement. +- **Cross-tz fragility:** Cheap path now (runtime fallback to live for + non-near-UTC zones). Proper iOS-sprint deliverable (per-user-tz + bucket minting) explicitly deferred to v1.5. +- **Drill-down 1000-cap surfacing:** Option (a) — Zod refine for the + 422 path. Pagination still deferred per W7c original choice. +- **Drain queue cutoff const lift:** done (per backlog). +- **Geo-backfill chunking:** drop cap to 500. Singleton-guard via pg-boss + `singletonKey` with advisory-lock fallback. + +## Waves + +### W-A — Cross-tz fast-path runtime guard (2 H items) +- Correlations fast-path: runtime guard that falls back to live for + non-near-UTC (>4 h) zones; same shape for bp-in-target. +- Touch: `src/lib/analytics/correlations-fast-path.ts`, + `src/lib/analytics/bp-in-target-fast-path.ts`, related tests. + +### W-B — Robustness sweep (~13 M items) +Bundle: +- Geo-backfill chunking + singleton-guard +- `DRAIN_CUMULATIVE_CUTOFF_HOURS` lift to shared module +- Drill-down `take` cap via Zod refine +- Analytics cached `daysAgo` derive +- `ensureUserRollupsFresh` per-key in-flight dedup (back-stop for the + v1.4.37.1 fire-and-forget — if a request DOES catch a sync recompute, + it shouldn't fan out N times) +- `recomputeBucketsForMeasurement` `Promise.all` parallel +- `looksLikeIp` strict via `net.isIP` +- Medication-card category-label drift-guard test +- MedicationIntakeQuickAdd refetch dedup +- Drain per-user complete-log +- BP fast-path leap year +- `degraded` sentinel removal until load-shedding lands +- `CORRELATION_WINDOW_DAYS` in OpenAPI +- Private `dayKey` helper rename to `bucketDayKey` +- Health-score `bpInTargetPct` shared across windows + +### W-C — Coach cascade test invariants (3 items) +- H4: COACH_SURFACES fixture absorbs cross-cut gates OR grep-based test +- M-5: SSR proof spy/CSR render replacement +- M6: Coach API route gate inventory test (discovery-style) + +### W-D — UX polish (P1+P2+P3 ~17 items) +- P1-1 drill-down chevron aria-controls +- P1-3 Hinzufügen menu max-w + label shortening +- P1-4 quick-add-labels collision-guard 6 locales +- P1-5 target-card gap measure (no-op confirm) +- P1-6 select chevron Safari `pr-2` bump +- P2-1 medication-intake empty-state footer promote +- P2-2 BMI structured-skeleton aria-live +- P2-3 GLP-1 take-now pill colour-blind icon +- P2-5 Dashboard items-center preference confirmation +- P3-1 through P3-7 — minor polish + +### W-E — i18n full localization (Option A — T1 + T2) +- T1 mandatory: W7b medication-intake-quick-add (13 keys) + W7c step + consolidation (3 keys) translated to es/fr/it/pl. +- T2 high-value: scan all patient-facing medical/health domain copy + (Insights cards, measurement labels, mood scale, medication intake, + Coach UI, Arztbericht hero, doctor-report PDF strings) for EN + fallback; translate to es/fr/it/pl. +- T3 deferred: Settings/Admin/Dev strings. + +### W-F — Two new perf hotspots (carry from v1.4.37.x verification) +- `/api/insights/comprehensive` (3.4s cold) — investigate over-fetch + pattern, likely same fix shape as v1.4.37.2 (SQL aggregation at the + DB level instead of row transfer) +- `/api/dashboard/summary` (4.6s cold) — iOS aggregator, parallel + queries on `measurements`. Likely candidates: per-metric latest read + combined with sparkline read; consider SQL aggregation or rollup- + based read + +### W-QA — Six-axis review pass (parallel) +Same dispatch as v1.4.37 W10. Six reviewer agents, findings docs, +single reconcile-apply for Critical/High. + +### W-RX — Reconcile-apply +Critical + High + selected-Medium fixes from W-QA before tag. + +### W-Release — Tag + deploy + verify + closure +Standard release flow. + +## Quality gates (per release contract) + +- typecheck clean +- lint 0 errors / 0 warnings on touched files +- unit suite green; integration stable (known flakes documented) +- no PII in CHANGELOG / GH release / docs / landing +- Marc-Voice English commits + user-facing copy +- No `Co-Authored-By: Claude`, no `--no-verify`, no `--no-gpg-sign` +- Touch-disjoint between parallel agents + +## Operating notes + +- Marc is not available — fully autonomous mode +- Verification: anonymous HTTP probes + Coolify application_logs + + Playwright headless (for unauthenticated surfaces) +- Cannot run authenticated UAT — log inspection takes that role +- All Marc-decisions defaulted per the Default Picks section above diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1720bdf..9520de090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.4.37.2] — 2026-05-17 — Slim summaries SQL aggregation + +Second hotfix on top of v1.4.37. The v1.4.37.1 fire-and-forget +release removed the 60 s event-loop block but live HAR still +showed `/api/analytics?slice=summaries` taking ~3.1 s per cache +miss. Root cause: the v1.4.35 implementation read EVERY DAY +rollup bucket for the user (`findMany` without a `bucketStart` +window) and composed `count / min / max / mean` in JavaScript. +On a power-user account that materialised as a ~306k-row +transfer plus a JS loop. The slim slice's contract is the +all-time per-type aggregate — exactly what a SQL `GROUP BY type` +returns in a single round-trip. + +### Fixed + +- `computeFromRollups` in `src/lib/analytics/summaries-slice.ts` + swaps the unwindowed `prisma.measurementRollup.findMany` for a + per-type `$queryRaw` GROUP BY that hands back 8 rows of + `count / min / max / mean` instead of ~306k bucket rows. The + downstream JS aggregation is bypassed because the per-type + aggregate is already shaped server-side. Cache-miss budget on + Marc-sized accounts: ~3.1 s → < 100 ms expected. + +### Operator notes + +- No schema change. No env-var change. No public API change + (the response body shape is identical to v1.4.37.1). +- Coolify auto-deploys main on tag push. + ## [1.4.37.1] — 2026-05-17 — Event-loop unblock on the read path Hotfix on top of v1.4.37. Post-deploy verification surfaced live diff --git a/package.json b/package.json index 83d04f3fd..28edafb4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.37.1", + "version": "1.4.37.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 436f072f7..7e62c2925 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -228,10 +228,15 @@ describe("computeSummariesSlice", () => { }); describe("rollup-fresh happy path", () => { - it("composes count/min/max/mean from DAY buckets without running the heavy aggregate", async () => { + it("composes count/min/max/mean from the per-type rollup GROUP BY without running the heavy aggregate", async () => { + // v1.4.37.2 — the slim slice's rollup read is now a per-type + // GROUP BY ($queryRaw) instead of a row-per-bucket findMany, + // so the mock sequence is: // 1. per-type coverage probe — WEIGHT fully covered ⇒ happy path. - // 2. narrow aggregate ($queryRaw) — windowed/regression only. - // 3. latests ($queryRaw). + // 2. narrow aggregate — windowed/regression only. + // 3. latests. + // 4. rollup GROUP BY — one row per type with count/min/max/mean + // already composed server-side. RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) .mockResolvedValueOnce([ { @@ -248,31 +253,26 @@ describe("computeSummariesSlice", () => { ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.7, measured_at: new Date() }, + ]) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + // pre-aggregated server-side: SUM(count), MIN(min), + // MAX(max), weighted mean — equivalent to the two-bucket + // fixture below. + // bucket A: count=10, mean=81.0, min=79.5, max=82.0 + // bucket B: count=10, mean=83.0, min=81.5, max=84.0 + // ⇒ count=20, min=79.5, max=84.0, mean=82 + count: 20, + min: 79.5, + max: 84.0, + mean: 82.0, + }, ]); - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - type: "WEIGHT", - bucketStart: new Date("2026-05-10T00:00:00.000Z"), - count: 10, - mean: 81.0, - minValue: 79.5, - maxValue: 82.0, - }, - { - type: "WEIGHT", - bucketStart: new Date("2026-05-11T00:00:00.000Z"), - count: 10, - mean: 83.0, - minValue: 81.5, - maxValue: 84.0, - }, - ]); - const result = await computeSummariesSlice("user-rollup"); const weight = result.summaries.WEIGHT; - // count = 10 + 10 = 20; min/max across buckets; weighted mean. expect(weight.count).toBe(20); expect(weight.min).toBe(79.5); expect(weight.max).toBe(84.0); @@ -285,9 +285,10 @@ describe("computeSummariesSlice", () => { confidence: 0.5, }); - // probe + narrow aggregate + latests — NOT the heavy aggregate. - expect(RAW).toHaveBeenCalledTimes(3); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1); + // probe + narrow aggregate + latests + rollup GROUP BY + // (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate. + expect(RAW).toHaveBeenCalledTimes(4); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(0); }); }); }); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 6ceb016d8..d1e15bcfa 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -58,7 +58,6 @@ import type { DataSummary } from "@/lib/analytics/trends"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { annotate } from "@/lib/logging/context"; import { ensureUserRollupsFresh } from "@/lib/measurements/rollups"; -import { aggregateBuckets } from "@/lib/measurements/rollup-read"; import { isFullyCovered, probeRollupCoverage, @@ -91,6 +90,20 @@ interface HeavyAggregateRow { * so the rollup-fresh read path's SQL projection is half as wide as * the legacy aggregate's. */ +/** + * v1.4.37.2 — shape returned by the per-type GROUP BY over + * `measurement_rollups`. Replaces the v1.4.35 row-per-bucket transfer + * that materialised ~306k rows on a power-user account; the SQL now + * does the aggregation server-side and hands back one row per type. + */ +interface RollupAggregateRow { + type: string; + count: number; + min: number; + max: number; + mean: number; +} + interface NarrowAggregateRow { type: string; avg7: number | null; @@ -272,24 +285,32 @@ async function computeFromRollups(userId: string): Promise { WHERE m."user_id" = ${userId} ORDER BY m."type", m."measured_at" DESC `, - // v1.4.35 — every DAY rollup bucket for the user. The slim slice's - // `count / min / max / mean` is all-time, so we read the bucket - // table without a `bucketStart` window. The defensive parity - // check below uses the live aggregate's `COUNT(*)` to decide - // whether the rollup is complete for this account (post-backfill - // → composed) or partial (pre-backfill → falls back to live). - prisma.measurementRollup.findMany({ - where: { userId, granularity: "DAY" }, - orderBy: [{ type: "asc" }, { bucketStart: "asc" }], - select: { - type: true, - bucketStart: true, - count: true, - mean: true, - minValue: true, - maxValue: true, - }, - }), + // v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY + // rollup bucket for the user (`findMany` without a `bucketStart` + // window) and then composed `count / min / max / mean` in JS. + // On a power-user account that materialised as a 306k-row + // transfer + a JS loop = ~3.85 s per cache miss, even with the + // rollup table hot. The slim slice's contract is the all-time + // count / min / max / mean per type — exactly what a SQL + // `GROUP BY type` returns in a single round-trip. Returns 8 rows + // instead of 306k, brings the cache-miss cost into the < 100 ms + // budget. The downstream `aggregateBuckets` call is bypassed + // because the per-type aggregate is already shaped server-side. + prisma.$queryRaw` + SELECT + "type"::text AS type, + SUM("count")::int AS count, + MIN("min_value")::double precision AS min, + MAX("max_value")::double precision AS max, + ( + SUM("count" * "mean")::double precision + / NULLIF(SUM("count")::double precision, 0) + ) AS mean + FROM "measurement_rollups" + WHERE "user_id" = ${userId} + AND "granularity" = 'DAY' + GROUP BY "type" + `, ]); const latestByType = new Map(); @@ -328,48 +349,30 @@ async function computeFromRollups(userId: string): Promise { }; } - // v1.4.35 — partition the DAY buckets by type so the per-type loop - // can compose `count / min / max / mean` from them in O(1). - const bucketsByType = new Map< - string, - Array<{ - day: Date; - count: number; - mean: number; - minValue: number; - maxValue: number; - }> - >(); - for (const b of dayBuckets) { - const list = bucketsByType.get(b.type) ?? []; - list.push({ - day: b.bucketStart, - count: b.count, - mean: b.mean, - minValue: b.minValue, - maxValue: b.maxValue, - }); - bucketsByType.set(b.type, list); - } - + // v1.4.37.2 hotfix — the rollup aggregate is now shaped server-side + // (one row per type with count / min / max / mean already computed), + // so we map straight through instead of partitioning + composing in + // JS. The narrows query feeds the windowed avgs + slope columns the + // GROUP BY cannot reconstruct. const narrowByType = new Map(); for (const row of narrows) { narrowByType.set(row.type, row); } let totalRows = 0; - for (const [type, buckets] of bucketsByType.entries()) { - const composed = aggregateBuckets(buckets); - if (composed.count === 0) continue; - totalRows += composed.count; - const narrow = narrowByType.get(type); - const latest = latestByType.get(type) ?? null; - summaries[type] = { - count: composed.count, + let typeCount = 0; + for (const row of dayBuckets) { + if (!row.count || row.count <= 0) continue; + typeCount += 1; + totalRows += row.count; + const narrow = narrowByType.get(row.type); + const latest = latestByType.get(row.type) ?? null; + summaries[row.type] = { + count: row.count, latest, - min: round2(composed.min), - max: round2(composed.max), - mean: round2(composed.mean), + min: round2(row.min), + max: round2(row.max), + mean: round2(row.mean), avg7: round2(narrow?.avg7 ?? null), avg30: round2(narrow?.avg30 ?? null), slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null), @@ -387,7 +390,7 @@ async function computeFromRollups(userId: string): Promise { analytics: { slim_summaries: { row_count: totalRows, - type_count: bucketsByType.size, + type_count: typeCount, path: "rollup", }, },