mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
chore: sync develop with main after v1.4.37.2 hotfix + seed v1.4.38 marathon kickoff
The v1.4.37.2 slim-summaries SQL aggregation landed directly on main during incident response; this merge brings develop back into parity and seeds the v1.4.38 marathon kickoff document.
This commit is contained in:
commit
dcd0b0a591
5 files changed
+237
-82
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SummariesSlice> {
|
||||
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<RollupAggregateRow[]>`
|
||||
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<string, number>();
|
||||
@@ -328,48 +349,30 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
};
|
||||
}
|
||||
|
||||
// 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<string, NarrowAggregateRow>();
|
||||
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<SummariesSlice> {
|
||||
analytics: {
|
||||
slim_summaries: {
|
||||
row_count: totalRows,
|
||||
type_count: bucketsByType.size,
|
||||
type_count: typeCount,
|
||||
path: "rollup",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user