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:
Marc-André Bombeck committed 2026-06-20 19:05:27 +02:00
commit 6975849bec
10 files changed
+357 -35

No files matched your search

+14 -3
View File
@@ -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 /
+28 -4
View File
@@ -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.
*
+27 -1
View File
@@ -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
+17
View File
@@ -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);
});
});
+26 -3
View File
@@ -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,
+62 -19
View File
@@ -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 {