From b5060f14222ddc29a220327b3f95b7d8b616699c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 17:13:34 +0200 Subject: [PATCH] refactor(insights): fold Math.min/max spreads in the six status helpers `bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood` status helpers all built their `summarizeSeries` block with `Math.min(...series.map(...))` and `Math.max(...series.map(...))`. The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts` on 2026-05-16 and crashed `/api/analytics` with `RangeError: Maximum call stack size exceeded` once an Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling; `61107e0c` folded that one. The six insight helpers are fed bounded windows today so they never reached the ceiling, but the spread still allocates a transient args array on every call and the fold is both stack-safe and cheaper. Replace each `Math.min(...) / Math.max(...) / average(...)` triplet with a single walk that maintains running sum / min / max. The now-unused `average()` helper is dropped from each file. Pinned by the existing per-helper unit tests (326 passing in `src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`). --- src/lib/insights/blood-pressure-status.ts | 26 +++++++++++++++------- src/lib/insights/bmi-status.ts | 27 ++++++++++++++++------- src/lib/insights/general-status.ts | 25 +++++++++++++++------ src/lib/insights/mood-status.ts | 26 +++++++++++++++------- src/lib/insights/pulse-status.ts | 26 +++++++++++++++------- src/lib/insights/weight-status.ts | 26 +++++++++++++++------- 6 files changed, 109 insertions(+), 47 deletions(-) diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index fde03d4e8..c7f03e84c 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -57,11 +57,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -74,14 +69,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index c669f932a..af3fe42ef 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -42,11 +42,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -59,14 +54,30 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path and is the same anti-pattern fixed in `summarize()` (see + // `.planning/round-v1433-analytics-500-report.md` §"Carry-over"). + // These helpers feed off bounded windows today so they did not crash, + // but the spread allocates a transient args array on every call; + // folding once is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index f89abdfaf..e48101dc7 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -51,10 +51,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -63,14 +59,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index ad413d742..f8acfb72f 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -49,11 +49,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -66,14 +61,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index a206b4deb..a5a5887ac 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -48,11 +48,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -65,14 +60,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index b8e1be4ec..4f0a72adf 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -52,11 +52,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -69,14 +64,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; }