fix(insights): roll the daily card cache over at the user's own midnight

The eleven per-metric/derived-score status helpers (pulse, weight,
bmi, mood, blood-pressure, medication-compliance, general, the generic
metric card, biomarker, the shared feature cache, the derived-score AI
warm layer) computed todayKey from toBerlinDayKey(new Date()) before
resolving the user's own timezone — several fetched the profile only
for downstream day-bucketing, after the cache read had already run on
the Berlin key. A non-Berlin user's card regenerated at their local
evening (Berlin midnight) and, for biomarker series and the
derived-score prompt label, mis-keyed data or displayed a hardcoded
"(Europe/Berlin)" regardless of their actual zone.

Threads resolveUserTimezone()/userDayKey() ahead of every cache read
so the day-key and the data bucketing agree, and reuses the one
resolved value everywhere downstream instead of re-deriving it.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 06:47:32 +02:00
1 parent 1e8e46f58c
commit dfa4533c7c
16 files changed
+376 -72

No files matched your search

@@ -5,6 +5,8 @@ vi.mock("@/lib/db", () => ({
biomarker: { findFirst: vi.fn() },
labResult: { findMany: vi.fn(), count: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
// v1.30.3 (QA F5) — `resolveUserTimezone` reads this directly.
user: { findUnique: vi.fn() },
},
}));
@@ -75,6 +77,9 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-20T09:00:00.000Z"),
} as never);
// Default: no stored timezone → resolveUserTimezone falls to the
// Europe/Berlin server default, matching every other fixture's assumption.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
});
describe("generateBiomarkerStatus — empty-data guard", () => {
@@ -180,6 +185,40 @@ describe("generateBiomarkerStatus — generation path", () => {
});
});
describe("generateBiomarkerStatus — per-user tz (QA F5)", () => {
it("day-keys the reading series in the USER's own tz, not Berlin's", async () => {
// 2026-06-20T23:30Z is 2026-06-21 01:30 in Berlin (UTC+2, DST) but still
// 2026-06-20 19:30 in New York (UTC-4, DST) — the two zones disagree on
// the calendar day. Before the fix every biomarker series day-keyed in
// Berlin regardless of the user's stored zone.
const reading = {
id: "r-ny",
value: 95,
takenAt: new Date("2026-06-20T23:30:00.000Z"),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "America/New_York",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
const capture = { userPrompt: "" };
stubCompletion('{"summary":"Stable."}', capture);
await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u-ny",
locale: "en",
});
const match = capture.userPrompt.match(/\{[\s\S]*\}/);
const snapshot = JSON.parse(match![0]);
expect(snapshot.series[0].day).toBe("2026-06-20");
expect(snapshot.series[0].day).not.toBe("2026-06-21");
});
});
describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => {
it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
@@ -103,6 +103,49 @@ describe("generateBmiStatusForUser — graded payload", () => {
});
});
describe("generateBmiStatusForUser — per-user tz (QA F5)", () => {
it("rolls the cache over at the USER's own midnight, not Berlin's", async () => {
// A moment that is already "tomorrow" in Berlin (UTC+2 summer) but still
// "today" in Los Angeles (UTC-7 summer) — the two zones disagree on the
// calendar day. Before the fix every card's cache day-key was Berlin-
// pinned regardless of the user's stored zone.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T23:30:00.000Z")); // Berlin: 06-21 01:30
const laToday = "2026-06-20"; // Los Angeles: 06-20 16:30
try {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
timezone: "America/Los_Angeles",
} as never);
// A cached row keyed to the LA calendar day should be served as a
// fresh hit even though it is already "tomorrow" in Berlin.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: laToday,
locale: "en",
text: "LA-anchored cached BMI text.",
model: "x",
}),
} as never);
const result = await generateBmiStatusForUser("user-la", {
locale: "en",
});
// If the day-key were still Berlin-pinned, this LA-dated row would
// MISS (today in Berlin is 06-21) and a real generation would run
// instead, serving the deterministic/no-key fallback rather than
// this cached text.
expect(result.cached).toBe(true);
expect(result.text).toBe("LA-anchored cached BMI text.");
expect(runStatusCompletion).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("generateBmiStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -6,9 +6,15 @@ vi.mock("@/lib/db", () => ({
},
}));
// Berlin day key is deterministic input to the cache key; pin it.
// The user-tz day key is deterministic input to the cache key; pin it, but
// keep `resolveUserTimezone` spy-able so QA F5 (tz threading, not a
// hardcoded Berlin day) has a wiring assertion below.
const { resolveUserTimezoneMock } = vi.hoisted(() => ({
resolveUserTimezoneMock: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/tz/resolver", () => ({
toBerlinDayKey: () => "2026-06-21",
resolveUserTimezone: resolveUserTimezoneMock,
userDayKey: () => "2026-06-21",
}));
vi.mock("@/lib/logging/context", () => ({
@@ -31,6 +37,7 @@ beforeEach(() => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue(
fingerprintRows(10) as never,
);
resolveUserTimezoneMock.mockResolvedValue("Europe/Berlin");
});
describe("getCachedFeatures (P3)", () => {
@@ -198,6 +205,21 @@ describe("getCachedFeatures (P3)", () => {
expect(compute).toHaveBeenCalledTimes(2);
});
it("resolves the day-key in the CALLING USER's own tz, not a hardcoded Berlin (QA F5)", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "z" });
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u-ny",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
expect(resolveUserTimezoneMock).toHaveBeenCalledWith("u-ny");
});
it("recomputes on a fingerprint probe failure rather than serving a stale object", async () => {
const compute = vi
.fn()
@@ -210,6 +210,46 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
});
});
describe("generatePulseStatusForUser — per-user tz (QA F5)", () => {
it("rolls the cache over at the USER's own midnight, not Berlin's", async () => {
// A moment that is already "tomorrow" in Berlin (UTC+2 summer) but still
// "today" in New York (UTC-4 summer). Before the fix, `todayKey` was
// computed from `toBerlinDayKey(new Date())` BEFORE the user's profile
// (and its timezone) was even read.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T23:30:00.000Z")); // Berlin: 06-21 01:30
const nyToday = "2026-06-20"; // New York: 06-20 19:30
try {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
timezone: "America/New_York",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: nyToday,
locale: "en",
text: "NY-anchored cached pulse text.",
model: "x",
}),
} as never);
const result = await generatePulseStatusForUser("user-ny", {
locale: "en",
});
// If the day-key were still Berlin-pinned, this NY-dated row would
// MISS (today in Berlin is 06-21) and a real generation would run.
expect(result.cached).toBe(true);
expect(result.text).toBe("NY-anchored cached pulse text.");
expect(runStatusCompletion).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
+8 -3
View File
@@ -42,7 +42,7 @@ import {
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
export interface BiomarkerStatusResult {
hasProvider: boolean;
@@ -148,7 +148,12 @@ export async function generateBiomarkerStatus(args: {
const readOnly = args.readOnly === true;
const scope = biomarkerStatusScope(marker.id);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path).
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId: args.userId,
@@ -302,7 +307,7 @@ export async function generateBiomarkerStatus(args: {
},
summary,
series: ascending.map((r) => ({
day: toBerlinDayKey(r.takenAt),
day: userDayKey(r.takenAt, userTz),
value: r.value,
})),
};
+11 -11
View File
@@ -64,11 +64,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
resolveUserTimezone,
toBerlinDayKey,
userDayKey,
} from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
@@ -164,7 +160,12 @@ export async function prepareBloodPressureStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("blood-pressure", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -250,11 +251,10 @@ export async function prepareBloodPressureStatusForUser(
const now = new Date();
// v1.7.0 SB-SCHED-2 / v1.2.5 (M-TZ3) — resolve the user timezone once,
// up front, so BOTH the BP-status compliance gate AND every day-bucketing
// pass below (applyPayloadBudget, pairDailyBuckets, the continuity series)
// key on the user's own calendar day, not Berlin's.
const userTz = await resolveUserTimezone(userId);
// v1.7.0 SB-SCHED-2 / v1.2.5 (M-TZ3) — BOTH the BP-status compliance gate
// AND every day-bucketing pass below (applyPayloadBudget, pairDailyBuckets,
// the continuity series) key on the user's own calendar day, not Berlin's.
// `userTz` was already resolved above for the cache day-key.
const weightSeries = applyPayloadBudget(
measurements
+8 -4
View File
@@ -45,7 +45,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { DEFAULT_TIMEZONE, toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Public entry — unchanged signature. Prepares the card (cache-read,
@@ -102,7 +102,12 @@ export async function prepareBmiStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("bmi", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -162,7 +167,6 @@ export async function prepareBmiStatusForUser(
where: { id: userId },
select: {
heightCm: true,
timezone: true,
},
});
@@ -236,7 +240,7 @@ export async function prepareBmiStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — bucket day windows on the user's own calendar so a
// near-midnight reading lands on the user's day for non-Berlin self-hosters.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// `userTz` was already resolved above for the cache day-key.
const heightFactor = (user.heightCm / 100) ** 2;
const bmiPoints = measurements.map((measurement) => ({
@@ -0,0 +1,111 @@
/**
* v1.30.3 (QA F5) — the derived-score AI warm layer must roll its cache over
* at the USER's own midnight (not Berlin's) and label the prompt's date line
* with the user's actual tz (not a hardcoded "(Europe/Berlin)").
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/status-cache", () => ({
readFreshStatusText: vi.fn(),
resolveReadOnlyStatusMiss: vi.fn(),
statusCacheAction: (scope: string, locale: string) =>
`insights.${scope}-status.${locale}`,
computeStatusInputFingerprint: vi.fn().mockResolvedValue("hash"),
gateUnchangedStatusInput: vi.fn().mockResolvedValue(null),
}));
const findUnique = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: (a: unknown) => findUnique(a) } },
}));
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
resolveDerivedAssessment,
generateDerivedScoreAssessment,
} from "../derived-assessment-ai";
import type { Derived } from "../types";
const RECOVERY_VALUE = {
score: 70,
band: "green" as const,
trendDelta: null,
daysInWindow: 10,
asOf: "2026-06-20T12:00:00.000Z",
series: [],
};
function okDerived(): Derived<unknown> {
return {
status: "ok",
value: RECOVERY_VALUE,
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band: "high" },
provenance: {
inputs: ["RECOVERY_SCORE"],
source: "DAY",
windowDays: 30,
computedAt: "x",
},
};
}
beforeEach(() => {
vi.resetAllMocks();
findUnique.mockResolvedValue({ timezone: "America/New_York" });
});
describe("resolveDerivedAssessment — per-user tz (QA F5)", () => {
it("resolves the user's own tz before reading the cache", async () => {
vi.mocked(readFreshStatusText).mockResolvedValue(null);
await resolveDerivedAssessment({
metric: "RECOVERY_SCORE",
userId: "u-ny",
derived: okDerived(),
locale: "en",
now: new Date("2026-06-20T23:30:00.000Z"), // Berlin: 06-21 already
});
expect(findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "u-ny" } }),
);
// The cache read must have run with the NY calendar day (06-20), not the
// Berlin one (06-21) — the fixed pre-fix behaviour.
const call = vi.mocked(readFreshStatusText).mock.calls[0][0] as {
todayKey: string;
};
expect(call.todayKey).toBe("2026-06-20");
});
});
describe("generateDerivedScoreAssessment — per-user tz label (QA F5)", () => {
it("labels the prompt's date line with the user's actual tz, not a hardcoded Berlin", async () => {
let capturedPrompt = "";
vi.mocked(runStatusCompletion).mockImplementation(async (args) => {
capturedPrompt = (args as { userPrompt: string }).userPrompt;
return { kind: "none" } as never;
});
await generateDerivedScoreAssessment({
metric: "RECOVERY_SCORE",
userId: "u-ny",
derived: okDerived(),
locale: "en",
now: new Date("2026-06-20T23:30:00.000Z"),
});
expect(capturedPrompt).toContain("(America/New_York)");
expect(capturedPrompt).not.toContain("(Europe/Berlin)");
});
});
@@ -41,7 +41,7 @@ import {
gateUnchangedStatusInput,
} from "@/lib/insights/status-cache";
import type { MeasurementType } from "@/generated/prisma/client";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import {
@@ -112,6 +112,8 @@ function scoreUserPrompt(
todayKey: string,
locale: SupportedLocale,
openerHint: string,
/** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */
tz: string,
): string {
const snapshot = JSON.stringify(
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
@@ -119,13 +121,13 @@ function scoreUserPrompt(
2,
);
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
return `Date: ${todayKey} (${tz})
OPENER HINT: ${openerHint}
Write an assessment of ${signal.metric} today across the four beats — the standing, what helped AND what hurt it, what the band means for the day, and one grounded nudge. Aim for 3–5 sentences, roughly 45–75 words (this overrides the shorter base length cap). Connected prose, not a checklist.
${snapshot}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
return `Datum: ${todayKey} (${tz})
OPENER-HINWEIS: ${openerHint}
Schreibe eine Einschätzung zu ${signal.metric} heute über die vier Beats — die Einordnung, was geholfen UND was gebremst hat, was das Band für den Tag bedeutet und ein gegroundeter Anstoß. Ziel sind 3–5 Sätze, rund 45–75 Wörter (das übersteuert die kürzere Basis-Längenvorgabe). Zusammenhängende Prosa, keine Checkliste.
@@ -161,7 +163,10 @@ export async function resolveDerivedAssessment(args: {
const scope = derivedScoreScope(args.metric);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(now);
// v1.30.3 (QA F5) — roll the cache over at the user's own midnight, not
// Berlin's.
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(now, userTz);
// Fresh AI text for today → serve it (warmer prose overrides the template).
const fresh = await readFreshStatusText({
@@ -215,7 +220,11 @@ export async function generateDerivedScoreAssessment(args: {
const scope = derivedScoreScope(args.metric);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(now);
// v1.30.3 (QA F5) — roll the cache over at the user's own midnight, not
// Berlin's; also threads into the prompt's date label below (was
// hardcoded "(Europe/Berlin)" regardless of the user's actual zone).
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(now, userTz);
// v1.22 (W6) — opener-archetype rotation + a day-rotated seed so a score
// assessment varies across scores and across days instead of riding the
@@ -254,7 +263,14 @@ export async function generateDerivedScoreAssessment(args: {
cacheAction,
consentSurface: "insights",
systemPrompt: scoreSystemPrompt(locale),
userPrompt: scoreUserPrompt(signal, band, todayKey, locale, openerHint),
userPrompt: scoreUserPrompt(
signal,
band,
todayKey,
locale,
openerHint,
userTz,
),
// v1.22 (W6) — nudged up from 0.45 for variety; the grounding gate below
// catches any number the warmer sampling might drift onto.
temperature: 0.55,
+12 -5
View File
@@ -29,8 +29,9 @@
* `getCachedFeatures` falls straight through to the computer — no global
* state, no cross-user leak, no stale-across-requests hazard.
* - The key is `(userId, day, includeRaw, sinceDays, inputHash)`. `day` is
* the Berlin calendar day so a scope that straddles midnight recomputes
* rather than serving yesterday's window. `inputHash` is a cheap
* the user's own calendar day (v1.30.3 QA F5 — was Berlin-pinned for
* every user) so a scope that straddles the user's local midnight
* recomputes rather than serving yesterday's window. `inputHash` is a cheap
* count/newest fingerprint of the salient measurement + mood inputs, so a
* mid-scope ingest (rare, but possible on a long-running warm) recomputes
* instead of reusing a pre-ingest snapshot.
@@ -43,7 +44,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
/** One memoised feature read, with the input fingerprint it was keyed on. */
@@ -154,8 +155,14 @@ export async function getCachedFeatures<T>(args: {
return args.compute();
}
const inputHash = await computeFeatureInputHash(args.userId);
const dayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — the day-key rolls over at the USER'S own midnight, not
// Berlin's, so a Coach/briefing scope spanning the user's local midnight
// recomputes at the right instant for that user.
const [inputHash, userTz] = await Promise.all([
computeFeatureInputHash(args.userId),
resolveUserTimezone(args.userId),
]);
const dayKey = userDayKey(new Date(), userTz);
const key = `${args.userId}|${dayKey}|${args.includeRaw ? "raw" : "agg"}|${args.sinceDays}`;
const hit = current.get(key);
+11 -10
View File
@@ -44,11 +44,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
resolveUserTimezone,
toBerlinDayKey,
userDayKey,
} from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
@@ -104,7 +100,12 @@ export async function prepareGeneralStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("general", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -191,10 +192,10 @@ export async function prepareGeneralStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing
// pass below keys on the user's own calendar (a near-midnight reading
// lands on the user's day for non-Berlin self-hosters).
const userTz = await resolveUserTimezone(userId);
// v1.2.5 (M-TZ3) — every day-bucketing pass below keys on the user's own
// calendar (a near-midnight reading lands on the user's day for
// non-Berlin self-hosters). `userTz` was already resolved above for the
// cache day-key.
// `dailyByType` keeps the `applyPayloadBudget` daily buckets for the
// downstream BP in-target pairing; the prompt embeds the compact
@@ -12,7 +12,7 @@ import {
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { getMedicationCategories } from "@/lib/medication-category";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks";
@@ -47,7 +47,6 @@ import {
type PreparedStatusCard,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
@@ -113,7 +112,12 @@ export async function prepareMedicationComplianceStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("medication-compliance", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
@@ -264,9 +268,9 @@ export async function prepareMedicationComplianceStatusForUser(
},
});
// v1.7.0 SB-SCHED-2 — resolve the user timezone once so the
// compliance-pillar denominators route through the canonical engine.
const userTz = await resolveUserTimezone(userId);
// v1.7.0 SB-SCHED-2 — the compliance-pillar denominators route through
// the canonical engine on the user's own tz. `userTz` was already
// resolved above for the cache day-key.
const medicationSnapshots = medications.map((medication) => {
const events = medicationEvents.filter(
+10 -6
View File
@@ -73,7 +73,7 @@ import {
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import {
readFreshStatusText,
refreshUnchangedStatusInsight,
@@ -83,7 +83,6 @@ import {
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
export interface MetricStatusResult {
hasProvider: boolean;
@@ -150,7 +149,12 @@ export async function generateMetricStatus(args: {
const readOnly = args.readOnly === true;
const scope = metricStatusScope(meta.id);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId: args.userId,
@@ -239,9 +243,9 @@ export async function generateMetricStatus(args: {
// to SLEEP_DURATION only so no other metric's status path changes.
const isSleep = meta.measurementType === "SLEEP_DURATION";
// v1.2.5 (M-TZ3) — resolve the user's timezone up front so the day
// bucketing below keys on the user's own calendar regardless of branch.
const userTz = await resolveUserTimezone(args.userId);
// v1.2.5 (M-TZ3) — the day bucketing below keys on the user's own
// calendar regardless of branch. `userTz` was already resolved above for
// the cache day-key.
const points: Array<{ measuredAt: Date; value: number }> = [];
let measurements: Array<{ measuredAt: Date }> = [];
+9 -5
View File
@@ -64,7 +64,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Drop the ranking-only `pooledSd` before a tag-influence row enters the
@@ -149,7 +149,12 @@ export async function prepareMoodStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("mood", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -291,9 +296,8 @@ export async function prepareMoodStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing
// pass below keys on the user's own calendar day.
const userTz = await resolveUserTimezone(userId);
// v1.2.5 (M-TZ3) — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const moodPoints = entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
+9 -9
View File
@@ -51,11 +51,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
toBerlinDayKey,
userDayKey,
DEFAULT_TIMEZONE,
} from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Public entry — unchanged signature. Prepares the card (cache-read,
@@ -112,7 +108,12 @@ export async function preparePulseStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("pulse", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. This has to
// happen ahead of the cache read below (the earliest possible return path),
// not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -172,7 +173,6 @@ export async function preparePulseStatusForUser(
select: {
dateOfBirth: true,
gender: true,
timezone: true,
},
});
@@ -180,8 +180,8 @@ export async function preparePulseStatusForUser(
// own day boundary so this surface aligns with the targets route, which
// passes `userDayKey(d, userTz)`. Without it the proxy bucketed on
// Berlin-day here and on the user's TZ there, drifting the resting
// estimate for non-Berlin users.
const userTz = user?.timezone ?? DEFAULT_TIMEZONE;
// estimate for non-Berlin users. `userTz` was already resolved above for
// the cache day-key.
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further but the unbounded findMany was
+9 -5
View File
@@ -56,7 +56,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
@@ -158,7 +158,12 @@ export async function prepareWeightStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("weight", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
// Serve today's real assessment when present. The shared reader
// rejects timeout stubs, so a single stall no longer pins the
@@ -283,9 +288,8 @@ export async function prepareWeightStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing
// pass below keys on the user's own calendar day.
const userTz = await resolveUserTimezone(userId);
// v1.2.5 (M-TZ3) — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const weightSeries = applyPayloadBudget(
measurements