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`).
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:11:27 +02:00
1 parent 61107e0c68
commit 2d630994a2
8 files changed
+66 -12

No files matched your search

+2 -1
View File
@@ -1639,7 +1639,8 @@
"genderHint": "Wird für geschlechtsspezifische Zielwerte verwendet.",
"timezone": "Zeitzone",
"timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.",
"timezoneDetect": "Browser-Zeitzone übernehmen ({tz})",
"timezoneDetect": "Browser-Zeitzone übernehmen",
"timezoneDetectAria": "Browser-Zeitzone {tz} übernehmen",
"timezoneInvalid": "Keine gültige IANA-Zeitzone.",
"profileSaved": "Profil gespeichert",
"changePassword": "Passwort ändern",
+2 -1
View File
@@ -1639,7 +1639,8 @@
"genderHint": "Used for gender-specific target values.",
"timezone": "Timezone",
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
"timezoneDetect": "Use browser timezone ({tz})",
"timezoneDetect": "Use browser timezone",
"timezoneDetectAria": "Use browser timezone {tz}",
"timezoneInvalid": "Not a valid IANA timezone.",
"profileSaved": "Profile saved",
"changePassword": "Change password",
+2 -1
View File
@@ -1639,7 +1639,8 @@
"genderHint": "Used for gender-specific target values.",
"timezone": "Zona horaria",
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
"timezoneDetect": "Use browser timezone ({tz})",
"timezoneDetect": "Use browser timezone",
"timezoneDetectAria": "Use browser timezone {tz}",
"timezoneInvalid": "Not a valid IANA timezone.",
"profileSaved": "Profile saved",
"changePassword": "Change password",
+2 -1
View File
@@ -1639,7 +1639,8 @@
"genderHint": "Used for gender-specific target values.",
"timezone": "Fuseau horaire",
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
"timezoneDetect": "Use browser timezone ({tz})",
"timezoneDetect": "Use browser timezone",
"timezoneDetectAria": "Use browser timezone {tz}",
"timezoneInvalid": "Not a valid IANA timezone.",
"profileSaved": "Profile saved",
"changePassword": "Change password",
+2 -1
View File
@@ -1639,7 +1639,8 @@
"genderHint": "Used for gender-specific target values.",
"timezone": "Fuso orario",
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
"timezoneDetect": "Use browser timezone ({tz})",
"timezoneDetect": "Use browser timezone",
"timezoneDetectAria": "Use browser timezone {tz}",
"timezoneInvalid": "Not a valid IANA timezone.",
"profileSaved": "Profile saved",
"changePassword": "Change password",
+2 -1
View File
@@ -1639,7 +1639,8 @@
"genderHint": "Used for gender-specific target values.",
"timezone": "Strefa czasowa",
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
"timezoneDetect": "Use browser timezone ({tz})",
"timezoneDetect": "Use browser timezone",
"timezoneDetectAria": "Use browser timezone {tz}",
"timezoneInvalid": "Not a valid IANA timezone.",
"profileSaved": "Profile saved",
"changePassword": "Change password",
+18 -2
View File
@@ -80,9 +80,14 @@ export function TimezonePicker({
variant="outline"
size="sm"
onClick={handleUseBrowserTz}
aria-label={
detected
? t("settings.timezoneDetectAria", { tz: detected })
: undefined
}
>
<Compass className="mr-2 h-4 w-4" />
{t("settings.timezoneDetect", { tz: detected })}
{t("settings.timezoneDetect")}
</Button>
</div>
);
@@ -109,15 +114,26 @@ export function TimezonePicker({
</option>
))}
</NativeSelect>
{/* v1.4.33 — visible label is "use browser zone"; the actual
IANA zone is read out via aria-label so screen-reader users
still hear which zone the click will apply. Previously the
label inlined `(Europe/Berlin)`, which on a 393 CSS px
viewport wrapped the button to two lines and broke height
parity with the adjacent select. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUseBrowserTz}
className="sm:w-auto"
aria-label={
detected
? t("settings.timezoneDetectAria", { tz: detected })
: undefined
}
>
<Compass className="mr-2 h-4 w-4" />
{t("settings.timezoneDetect", { tz: detected })}
{t("settings.timezoneDetect")}
</Button>
</div>
<p className="text-muted-foreground text-xs">{labelHint}</p>
+36 -4
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -13,16 +14,47 @@ import { useTranslations } from "@/lib/i18n/context";
* having to pull `locale` out of `useTranslations()` just to feed it
* back into the same JSX prop, and guarantees a future date input
* cannot silently regress by forgetting the hint.
*
* v1.4.33 — height parity with `<Input>` and `<NativeSelect>`.
*
* Mobile Safari + Android Chrome give `type="date"` (and
* `type="datetime-local"`) an intrinsic chrome that puffs the
* field past Tailwind's `h-10`, so the DOB cell on
* `/settings/account` rendered ~6 px taller than its row siblings
* and roughly 12 px wider on the Pixel-5 mobile-pass screenshots.
* `appearance-none` switches the native widget off, then
* `min-h-10` re-asserts the contract that every other input
* primitive ships. The `:webkit-date-and-time-value` shadow rule
* forces the embedded value text to occupy at least one full line
* even when iOS shrinks its placeholder, so the field looks
* identical on both engines.
*/
const DATE_INPUT_HEIGHT_CLASSES =
"appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
type InputProps = React.ComponentProps<typeof Input>;
type DateInputProps = Omit<InputProps, "type" | "lang">;
export function DateInput(props: DateInputProps) {
export function DateInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return <Input type="date" lang={locale} {...props} />;
return (
<Input
type="date"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}
export function DateTimeInput(props: DateInputProps) {
export function DateTimeInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return <Input type="datetime-local" lang={locale} {...props} />;
return (
<Input
type="datetime-local"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}