From f3af5d361a60c1c95ed9932f2c58b3bd03c2292d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 19:04:31 +0200 Subject: [PATCH] perf(gamification,dashboard,insights): cut achievements cold rebuilds and lazy-load three charts Achievements: the bucket was 60 s hard-TTL while the app-wide unlock notifier polls every 2 minutes, so every poll landed on an expired entry and fired a cold ~400 ms rebuild in the foreground. Add a 10-minute stale-while-revalidate window and read the route through cachedSwr; the route already persists pendingUnlocks outside the cached factory, so a stale serve never skips an unlock write. Achievements builder: the vitals array ran through several full-array passes, each re-deriving a Berlin day-key (and hour) per row via Intl.DateTimeFormat. Compute each row's day-key once and thread the parallel array into the green-day, weekly-consistency, engagement, and hidden passes; the hidden pass reuses a single precomputed hour array. Results are byte-identical to the per-row derivation (parity test added). dashboard/summary: read through cachedSwr so the iOS-polled endpoint serves its existing 1 h stale window instead of re-paying the cold rebuild every 60 s on the plain cached path. Charts: route the three recharts consumers that bypassed the shared next/dynamic boundary (lab biomarker chart, sleep stage stacked bar, recommendation rationale charts) through dynamic imports with matching loading skeletons, taking recharts off the first-load JS for /labs/[id], /insights/sleep, and the insights recommendations surface. --- src/app/api/dashboard/summary/route.ts | 17 ++- .../api/gamification/achievements/route.ts | 10 +- .../__tests__/recommendation-card.test.tsx | 23 ++++ .../insights/recommendation-card.tsx | 42 ++++++- src/components/insights/sleep-overview.tsx | 32 ++++- src/components/labs/lab-biomarker-detail.tsx | 28 ++++- src/lib/cache/server-cache.ts | 17 +++ .../__tests__/expansion-metrics.test.ts | 113 ++++++++++++++++++ src/lib/gamification/achievements-result.ts | 29 ++++- src/lib/gamification/expansion-metrics.ts | 81 ++++++++++--- 10 files changed, 357 insertions(+), 35 deletions(-) diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 4ef07e50d..93dd872bf 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -55,7 +55,7 @@ import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { summarizeSleepNights, @@ -328,12 +328,23 @@ export const GET = apiHandler(async () => { // it). const userTz = user.timezone ?? DEFAULT_TIMEZONE; - // v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU. + // v1.4.38 W-F — wrap the whole response in the analytics LRU. // Subsequent iOS polls inside the TTL hit memory; measurement / // mood / medication writes invalidate the user-bucket via the // existing `invalidateUserMeasurements` + the v1.4.38-extended // `invalidateUserMedications` hooks. - const body = await cached( + // + // v1.18.11 (W5 perf) — read via `cachedSwr`. The `analytics` bucket + // already provisions a 1 h `staleTtlMs` window, but this endpoint read + // through plain `cached` and so ignored it: the iOS client polls every + // 60 s, and a plain read past the 60 s fresh TTL re-paid the full cold + // rebuild on every poll. `cachedSwr` serves the prior body instantly + // inside the stale window and warms one background recompute instead. + // Writes still mark/evict the bucket, so a user's own action is + // reflected on the very next read — the window only bounds wall-clock + // drift (the same self-correcting `nextDueOverdue`/tally drift the + // snapshot read already tolerates). + const body = await cachedSwr( caches.analytics as ServerCache< Awaited> >, diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index 8cbb938b5..0661bcbdc 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildAchievementsResult, type AchievementsResult, @@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => { // a toggle change is reflected on the next read. const moduleMap = await resolveModuleMap(user.id); - const result = await cached( + // v1.18.11 (W5 perf) — read via `cachedSwr`. The bucket carries a + // 10-minute stale window; the app-wide `AchievementUnlockNotifier` polls + // every 2 minutes, so a hard-TTL read always missed and re-paid the cold + // build. SWR serves the prior payload instantly and warms one background + // recompute. Persistence of `pendingUnlocks` stays OUTSIDE this read (see + // below), so a stale-served body never skips an unlock write. + const result = await cachedSwr( caches.achievements as ServerCache, user.id, () => buildAchievementsResult(user, moduleMap), diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index bd8ad333a..952f1068f 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -33,6 +33,29 @@ vi.mock("@/components/charts/mood-chart", () => ({ ), })); +// v1.18.11 (W5 perf) — the rationale charts now route through `next/dynamic` +// so recharts is off the insights first-load JS. Under `renderToStaticMarkup` +// a `dynamic(..., { ssr:false })` boundary paints its loading skeleton (it +// never resolves the lazy chunk synchronously). For the chart-SELECTION +// assertions below we stub `next/dynamic` to a passthrough that renders the +// (already-mocked) underlying chart component eagerly, so the test still +// verifies the card picks the right chart kind for a metric. Production keeps +// the real lazy boundary + skeleton. +vi.mock("next/dynamic", async () => { + const health = await import("@/components/charts/health-chart"); + const mood = await import("@/components/charts/mood-chart"); + return { + default: (loader: unknown) => { + // Identify which chart the loader targets by its source text. + const src = String(loader); + const Comp = ( + src.includes("mood-chart") ? mood.MoodChart : health.HealthChart + ) as React.ComponentType>; + return (props: Record) => ; + }, + }; +}); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: null, isLoading: false }), useMutation: () => ({ diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 41f759414..794d44ad3 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, type ComponentProps } from "react"; import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; @@ -9,8 +9,10 @@ import type { InsightRecommendation, InsightRecommendationRationale, } from "@/lib/ai/types"; -import { HealthChart } from "@/components/charts/health-chart"; -import { MoodChart } from "@/components/charts/mood-chart"; +import dynamic from "next/dynamic"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; import { getMedicalReferenceById } from "@/lib/ai/medical-references"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import type { Locale } from "@/lib/i18n/config"; @@ -21,6 +23,40 @@ import { } from "./recommendation-feedback"; import { ConfidenceMeter } from "./confidence-meter"; +/** + * v1.18.11 (W5 perf) — route both rationale mini-charts through + * `next/dynamic` so recharts no longer rides the main insights surface's + * first-load JS. Each chart only paints inside an expanded rationale block, + * so deferring it costs nothing visible and matches the loading skeleton + * the canonical `health-chart-dynamic.tsx` boundary uses (`mini` here, + * since both charts render with the `mini` prop in the rationale card). + */ +const HealthChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/health-chart")).then( + (mod) => ({ default: mod.HealthChart }), + ), + { ssr: false, loading: () => }, +); +const HealthChart = (props: ComponentProps) => ( + + + +); + +const MoodChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/mood-chart")).then( + (mod) => ({ default: mod.MoodChart }), + ), + { ssr: false, loading: () => }, +); +const MoodChart = (props: ComponentProps) => ( + + + +); + /** * Collapsible recommendation card with severity badge, confidence * meter, and an expandable rationale block (window / compared-to / diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index 6ff200c1c..fa561ca97 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import type { ComponentProps } from "react"; import { Moon } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; @@ -10,12 +11,35 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; -import { - SleepStageStackedBar, - type SleepStageBreakdown, -} from "./sleep-stage-stacked-bar"; +import dynamic from "next/dynamic"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; +import type { SleepStageBreakdown } from "./sleep-stage-stacked-bar"; import { SleepDurationChart } from "./sleep-duration-chart"; +// v1.18.11 (W5 perf) — the stage stacked bar is the only recharts consumer +// on this page (the duration chart already routes through +// `health-chart-dynamic`). Defer it through `next/dynamic` so recharts is +// off `/insights/sleep`'s first-load JS; the `` loading shell +// matches the card the bar paints so the layout stays stable. +const SleepStageStackedBarLazy = dynamic( + () => + importWithRetry(() => import("./sleep-stage-stacked-bar")).then((mod) => ({ + default: mod.SleepStageStackedBar, + })), + { ssr: false, loading: () => }, +); +function SleepStageStackedBar( + props: ComponentProps, +) { + return ( + + + + ); +} + /** * v1.4.25 W4c — Sleep insights composition. * diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 1931518d8..0db4c7ed4 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -18,8 +18,13 @@ import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import dynamic from "next/dynamic"; +import type { ComponentProps } from "react"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; + import { BiomarkerForm } from "./biomarker-form"; -import { LabBiomarkerChart } from "./lab-biomarker-chart"; import { LabForm } from "./lab-form"; import { LabHistoryList } from "./lab-history-list"; import { ReferenceRangeBadge } from "./reference-range-badge"; @@ -29,6 +34,27 @@ import type { LabResultListResponse, } from "./types"; +// v1.18.11 (W5 perf) — defer the recharts biomarker chart through +// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS. +// The chart only paints once the reading list resolves; the `` +// loading shell matches the in-card chart footprint so the layout is stable. +const LabBiomarkerChartLazy = dynamic( + () => + importWithRetry(() => import("./lab-biomarker-chart")).then((mod) => ({ + default: mod.LabBiomarkerChart, + })), + { ssr: false, loading: () => }, +); +function LabBiomarkerChart( + props: ComponentProps, +) { + return ( + + + + ); +} + /** * v1.18.1 — per-biomarker detail: heading + current-value badge, the proper * dashboard-style chart with the reference band, and the editable reading diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 4945d12d4..b2baab11e 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -484,10 +484,27 @@ export const caches = { ttlMs: 60_000, staleTtlMs: 600_000, }), + /** + * v1.18.11 (W5 perf) — stale-while-revalidate window. The + * `AchievementUnlockNotifier` is mounted on every authenticated page and + * polls this endpoint every 2 minutes. Against a 60 s hard-TTL bucket + * that poll always landed on an expired entry, firing a cold ~400 ms + * rebuild on every session every 2 minutes. With a stale window the poll + * (and the inline `/achievements` page mount) serves the prior payload + * instantly while one coalesced background recompute refreshes it. + * + * SWR is safe here: the route persists `pendingUnlocks` OUTSIDE the + * cached factory (idempotent `createMany({ skipDuplicates: true })`), so + * serving a stale body never skips an unlock write. The 10-minute window + * mirrors the `medications` bucket; measurement / mood / medication / + * intake writes invalidate the `${userId}|` prefix so a user's own + * action still surfaces on the next read. + */ achievements: new ServerCache({ name: "achievements", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, }), dashboardWidgets: new ServerCache({ name: "dashboardWidgets", diff --git a/src/lib/gamification/__tests__/expansion-metrics.test.ts b/src/lib/gamification/__tests__/expansion-metrics.test.ts index 4008d45f2..4a5671a16 100644 --- a/src/lib/gamification/__tests__/expansion-metrics.test.ts +++ b/src/lib/gamification/__tests__/expansion-metrics.test.ts @@ -7,6 +7,19 @@ import { getHiddenMetrics, getMoodMetrics, } from "@/lib/gamification/expansion-metrics"; +import { toBerlinDayKey } from "@/lib/gamification/achievements"; + +// Mirror of the module-private Berlin-hour derivation so the parity test can +// build a CORRECT precomputed `measurementHours` array. +const BERLIN_HOUR_FORMATTER = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + hour: "2-digit", + hour12: false, +}); +function berlinHour(date: Date): number { + const parsed = Number.parseInt(BERLIN_HOUR_FORMATTER.format(date), 10); + return Number.isNaN(parsed) ? 0 : parsed; +} describe("countMeasurementsByType", () => { it("counts each type independently", () => { @@ -298,3 +311,103 @@ describe("buildExpansionMetricValues — integration smoke", () => { } }); }); + +// v1.18.11 (W5 perf) — the achievements builder now precomputes each vitals +// row's Berlin day-key (and hour) ONCE and threads the parallel arrays into +// the engagement + hidden passes instead of re-running `Intl.DateTimeFormat` +// per row per pass. The optimisation must be a pure speed-up: supplying the +// (correct) precomputed arrays has to yield results byte-identical to the +// fallback path that derives them per row. +describe("achievements Intl precompute — byte-identical parity", () => { + // Spread across hours (incl. night-owl 02-04h / early-bird 04-06h Berlin), + // a leap day, and multiple distinct days so every counter is exercised. + const measurements = [ + { type: "WEIGHT", measuredAt: new Date("2024-02-29T03:30:00Z") }, // leap + night-owl + { type: "PULSE", measuredAt: new Date("2026-04-01T03:10:00Z") }, // early-bird (05:10 Berlin DST) + { + type: "BLOOD_PRESSURE_SYS", + measuredAt: new Date("2026-04-01T09:00:00Z"), + }, + { type: "WEIGHT", measuredAt: new Date("2026-04-02T22:45:00Z") }, + { type: "PULSE", measuredAt: new Date("2026-04-03T12:00:00Z") }, + ]; + const moodEntries = [ + { + date: "2026-04-01", + score: 4, + moodLoggedAt: new Date("2026-04-01T20:00:00Z"), + }, + ]; + const intakeEvents = [ + { + scheduledFor: new Date("2026-04-02T07:00:00Z"), + takenAt: new Date("2026-04-02T07:05:00Z"), + skipped: false, + }, + ]; + const auditEvents = [ + { + action: "doctor-report.pdf.generate", + createdAt: new Date("2026-04-01T10:00:00Z"), + }, + ]; + + const measurementDayKeys = measurements.map((m) => + toBerlinDayKey(m.measuredAt), + ); + const measurementHours = measurements.map((m) => berlinHour(m.measuredAt)); + + it("getEngagementMetrics: precomputed day-keys match per-row derivation", () => { + const fallback = getEngagementMetrics({ + measurements, + moodEntries, + intakeEvents, + }); + const precomputed = getEngagementMetrics({ + measurements, + moodEntries, + intakeEvents, + measurementDayKeys, + }); + expect(precomputed).toEqual(fallback); + }); + + it("getHiddenMetrics: precomputed day-keys + hours match per-row derivation", () => { + const fallback = getHiddenMetrics({ + measurements, + moodEntries, + intakeEvents, + auditEvents, + }); + const precomputed = getHiddenMetrics({ + measurements, + moodEntries, + intakeEvents, + auditEvents, + measurementDayKeys, + measurementHours, + }); + expect(precomputed).toEqual(fallback); + // Sanity: the fixture exercises the offset-independent hidden counters + // (leap day = 2024-02-29 Berlin-local; doctor-report audit event). + expect(fallback.leapDayCount).toBe(1); + expect(fallback.doctorPdfCount).toBe(1); + }); + + it("buildExpansionMetricValues: precomputed day-keys match fallback", () => { + const fallback = buildExpansionMetricValues({ + measurements, + moodEntries, + intakeEvents, + auditEvents, + }); + const precomputed = buildExpansionMetricValues({ + measurements, + moodEntries, + intakeEvents, + auditEvents, + measurementDayKeys, + }); + expect(precomputed).toEqual(fallback); + }); +}); diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index 6a3dbda78..3697ee891 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -197,6 +197,11 @@ function getEventDaySeries(dates: Date[]): { function getHealthGreenDaySeries( measurements: MeasurementRecord[], heightCm: number | null, + // v1.18.11 (W5 perf) — Berlin day-keys precomputed once per vitals row by + // the caller and passed in parallel to `measurements`. Falls back to a + // per-row `toBerlinDayKey` when absent so the standalone callers / tests + // keep working; the result is byte-identical either way. + dayKeys?: string[], ) { const dayStats = new Map< string, @@ -208,8 +213,9 @@ function getHealthGreenDaySeries( } >(); - for (const measurement of measurements) { - const dayKey = toBerlinDayKey(measurement.measuredAt); + for (let i = 0; i < measurements.length; i++) { + const measurement = measurements[i]; + const dayKey = dayKeys?.[i] ?? toBerlinDayKey(measurement.measuredAt); const bucket = dayStats.get(dayKey) ?? { weight: [], bpSys: [], @@ -702,9 +708,20 @@ export async function buildAchievementsResult( ...passwordLoginDates, ]); + // v1.18.11 (W5 perf) — the vitals array runs through several independent + // full-array passes, each previously re-deriving a Berlin day-key per row + // via `Intl.DateTimeFormat.formatToParts` (~16.8k rows × ~3 passes on a + // power-user account dominated the cold build). Compute each row's day-key + // ONCE here and thread the parallel array into every consumer; the values + // are identical to the per-pass derivation, so the output is byte-identical. + const vitalsDayKeys = (measurements as MeasurementRecord[]).map((m) => + toBerlinDayKey(m.measuredAt), + ); + const healthSeries = getHealthGreenDaySeries( measurements as MeasurementRecord[], user.heightCm, + vitalsDayKeys, ); const onTimeSeries = getOnTimePerfectDaySeries( intakeEvents, @@ -725,7 +742,9 @@ export async function buildAchievementsResult( sleepMeasurements.map((m) => m.measuredAt), ); const weeklyConsistency = getWeeklyConsistency( - measurements.map((m) => toBerlinDayKey(m.measuredAt)), + // v1.18.11 (W5 perf) — reuse the day-keys computed once above instead of + // a fresh per-row `toBerlinDayKey` pass over the full vitals array. + vitalsDayKeys, MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK, MEASUREMENT_CONSISTENCY_TARGET_WEEKS, ); @@ -743,6 +762,10 @@ export async function buildAchievementsResult( moodEntries, intakeEvents, auditEvents, + // v1.18.11 (W5 perf) — reuse the vitals day-keys computed once above so + // the engagement + hidden passes don't re-walk the full vitals array + // through `Intl.DateTimeFormat` a second and third time. + measurementDayKeys: vitalsDayKeys, }); const earnability = getEarnabilityFlags({ hasMedication: medications.length > 0, diff --git a/src/lib/gamification/expansion-metrics.ts b/src/lib/gamification/expansion-metrics.ts index 412a5679f..e96aa399c 100644 --- a/src/lib/gamification/expansion-metrics.ts +++ b/src/lib/gamification/expansion-metrics.ts @@ -152,20 +152,34 @@ export function getEngagementMetrics(input: { measurements: MeasurementRecord[]; moodEntries: MoodEntryRecord[]; intakeEvents: IntakeEventRecord[]; + /** + * v1.18.11 (W5 perf) — Berlin day-keys for `measurements`, precomputed + * once by the caller and passed in parallel to the array. When present the + * measurement rows reuse these instead of re-deriving a `toBerlinDayKey` + * per row; mood / intake timestamps (far fewer) still derive inline. Falls + * back to per-row derivation when absent — byte-identical result either way. + */ + measurementDayKeys?: string[]; }): { consistentMonthCount: number; entryDayStreak: number; weekendStreakCount: number; } { - const allDates: Date[] = []; - for (const m of input.measurements) allDates.push(m.measuredAt); - for (const e of input.moodEntries) allDates.push(e.moodLoggedAt); + const dayKeyAccum: string[] = []; + for (let i = 0; i < input.measurements.length; i++) { + dayKeyAccum.push( + input.measurementDayKeys?.[i] ?? + toBerlinDayKey(input.measurements[i].measuredAt), + ); + } + for (const e of input.moodEntries) + dayKeyAccum.push(toBerlinDayKey(e.moodLoggedAt)); for (const i of input.intakeEvents) { - if (i.takenAt) allDates.push(i.takenAt); - else if (i.skipped) allDates.push(i.scheduledFor); + if (i.takenAt) dayKeyAccum.push(toBerlinDayKey(i.takenAt)); + else if (i.skipped) dayKeyAccum.push(toBerlinDayKey(i.scheduledFor)); } - if (allDates.length === 0) { + if (dayKeyAccum.length === 0) { return { consistentMonthCount: 0, entryDayStreak: 0, @@ -173,9 +187,7 @@ export function getEngagementMetrics(input: { }; } - const uniqueDays = Array.from( - new Set(allDates.map((d) => toBerlinDayKey(d))), - ).sort(); + const uniqueDays = Array.from(new Set(dayKeyAccum)).sort(); const entryDayStreak = calculateLongestStreak(uniqueDays); // consistent-month: did any Berlin-local calendar month have ≥25 @@ -256,6 +268,16 @@ export function getHiddenMetrics(input: { moodEntries: MoodEntryRecord[]; intakeEvents: IntakeEventRecord[]; auditEvents: AuditLogRecord[]; + /** + * v1.18.11 (W5 perf) — Berlin day-keys + hours for `measurements`, + * precomputed once by the caller and passed in parallel to the array. When + * present the measurement rows reuse these instead of re-running + * `Intl.DateTimeFormat` per row for both the hour and the day-key; mood / + * intake timestamps (far fewer) still derive inline. Byte-identical to the + * per-row derivation either way. + */ + measurementDayKeys?: string[]; + measurementHours?: number[]; }): { nightOwlCount: number; earlyBirdCount: number; @@ -267,20 +289,25 @@ export function getHiddenMetrics(input: { let earlyBird = 0; let leapDay = 0; - const allTimestamps: Date[] = []; - for (const m of input.measurements) allTimestamps.push(m.measuredAt); - for (const e of input.moodEntries) allTimestamps.push(e.moodLoggedAt); - for (const i of input.intakeEvents) { - if (i.takenAt) allTimestamps.push(i.takenAt); - } - - for (const ts of allTimestamps) { - const hour = berlinHour(ts); + const tally = (hour: number, dayKey: string): void => { if (hour >= 2 && hour < 4) nightOwl += 1; if (hour >= 4 && hour < 6) earlyBird += 1; - const dayKey = toBerlinDayKey(ts); // Feb 29 — only valid in leap years. if (dayKey.endsWith("-02-29")) leapDay += 1; + }; + + for (let i = 0; i < input.measurements.length; i++) { + const ts = input.measurements[i].measuredAt; + tally( + input.measurementHours?.[i] ?? berlinHour(ts), + input.measurementDayKeys?.[i] ?? toBerlinDayKey(ts), + ); + } + for (const e of input.moodEntries) { + tally(berlinHour(e.moodLoggedAt), toBerlinDayKey(e.moodLoggedAt)); + } + for (const i of input.intakeEvents) { + if (i.takenAt) tally(berlinHour(i.takenAt), toBerlinDayKey(i.takenAt)); } let doctorPdfCount = 0; @@ -353,19 +380,35 @@ export function buildExpansionMetricValues(input: { moodEntries: MoodEntryRecord[]; intakeEvents: IntakeEventRecord[]; auditEvents: AuditLogRecord[]; + /** + * v1.18.11 (W5 perf) — Berlin day-keys for `measurements`, precomputed once + * by the caller (the achievements builder already derives them for the + * green-day / weekly-consistency passes) and threaded into the engagement + + * hidden passes so the full vitals array is `Intl`-walked once, not ~3×. + */ + measurementDayKeys?: string[]; }): ExpansionMetrics { const counts = countMeasurementsByType(input.measurements); const mood = getMoodMetrics(input.moodEntries); + // The hidden pass also needs the per-row Berlin hour; derive it once here + // (only when the caller supplied day-keys, i.e. on the hot achievements + // path) so a single hour pass replaces the per-pass re-derivation. + const measurementHours = input.measurementDayKeys + ? input.measurements.map((m) => berlinHour(m.measuredAt)) + : undefined; const engagement = getEngagementMetrics({ measurements: input.measurements, moodEntries: input.moodEntries, intakeEvents: input.intakeEvents, + measurementDayKeys: input.measurementDayKeys, }); const hidden = getHiddenMetrics({ measurements: input.measurements, moodEntries: input.moodEntries, intakeEvents: input.intakeEvents, auditEvents: input.auditEvents, + measurementDayKeys: input.measurementDayKeys, + measurementHours, }); return {