mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
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