mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge w11/perf into release/v1.18.11
Serve achievements via SWR and precompute Berlin day-keys to kill the per-row Intl cost; switch dashboard/summary to cachedSwr; lazy-load three charts that bypassed the dynamic boundary.
This commit is contained in:
commit
6975849bec
10 files changed
+357
-35
No files matched your search
@@ -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<ReturnType<typeof buildDashboardSummary>>
|
||||
>,
|
||||
|
||||
@@ -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<AchievementsResult>,
|
||||
user.id,
|
||||
() => buildAchievementsResult(user, moduleMap),
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
return (props: Record<string, unknown>) => <Comp {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useMutation: () => ({
|
||||
|
||||
@@ -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: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
const HealthChart = (props: ComponentProps<typeof HealthChartLazy>) => (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
|
||||
const MoodChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/mood-chart")).then(
|
||||
(mod) => ({ default: mod.MoodChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
const MoodChart = (props: ComponentProps<typeof MoodChartLazy>) => (
|
||||
<ChartErrorBoundary>
|
||||
<MoodChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
|
||||
/**
|
||||
* Collapsible recommendation card with severity badge, confidence
|
||||
* meter, and an expandable rationale block (window / compared-to /
|
||||
|
||||
@@ -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 `<ChartSkeleton>` 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: () => <ChartSkeleton /> },
|
||||
);
|
||||
function SleepStageStackedBar(
|
||||
props: ComponentProps<typeof SleepStageStackedBarLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<SleepStageStackedBarLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W4c — Sleep insights composition.
|
||||
*
|
||||
|
||||
@@ -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 `<ChartSkeleton>`
|
||||
// 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: () => <ChartSkeleton /> },
|
||||
);
|
||||
function LabBiomarkerChart(
|
||||
props: ComponentProps<typeof LabBiomarkerChartLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<LabBiomarkerChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — per-biomarker detail: heading + current-value badge, the proper
|
||||
* dashboard-style chart with the reference band, and the editable reading
|
||||
|
||||
Vendored
+17
@@ -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<unknown>({
|
||||
name: "achievements",
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_000,
|
||||
staleTtlMs: 600_000,
|
||||
}),
|
||||
dashboardWidgets: new ServerCache<unknown>({
|
||||
name: "dashboardWidgets",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user