Merge branch 'fix/aggregation-day-boundaries' into release/v1.39.3

# Conflicts:
#	src/app/api/medications/[id]/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:54 +02:00
commit e2814336e5
208 files changed
+4612 -2164

No files matched your search

+10 -7
View File
@@ -55355,8 +55355,8 @@ components:
description: Trailing-window length in days for the chosen range.
granularity:
type: string
description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage
miss).
description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`), `live` for SLEEP_DURATION
(nights are reconstructed from the stage rows), or `none` when there is no data.
current:
description: Aggregate over the current window `[now-N, now)`.
$ref: "#/components/schemas/AnalyticsWindowAggregate"
@@ -55383,9 +55383,10 @@ components:
- delta
- deltaPct
additionalProperties: false
description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY
rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across
buckets; SD/slope/r² are intentionally excluded (not composable).
description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the
rollup tier and composes a count-weighted-mean delta, one source per day. Step-like totals compare average daily
totals and SLEEP_DURATION compares average nights, not per-reading means. `count/min/max/mean/sum` are linearly
composable across buckets; SD/slope/r² are intentionally excluded (not composable).
AnalyticsWindowAggregate:
type: object
properties:
@@ -55393,7 +55394,8 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Reading count composed across buckets.
description: Reading count composed across buckets. For step-like totals it is the number of days with a total, for
SLEEP_DURATION the number of nights.
min:
anyOf:
- type: number
@@ -55408,7 +55410,8 @@ components:
anyOf:
- type: number
- type: "null"
description: Count-weighted mean across buckets; null when empty.
description: Count-weighted mean across buckets; null when empty. For step-like totals (steps, energy, distance,
flights) it is the average daily total, for SLEEP_DURATION the average time asleep per night.
sum:
anyOf:
- type: number
@@ -0,0 +1,75 @@
/**
* Unit tests for the `healthlog/no-default-zone-literal` rule: the literal is
* banned in application source, allowed in `src/lib/tz/` and in tests, and
* the pending files are exempt until their owner switches them.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../no-default-zone-literal.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
ruleTester.run("no-default-zone-literal", rule, {
valid: [
{
code: 'import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const tz = user.timezone || DEFAULT_TIMEZONE;',
filename: "/repo/src/app/api/insights/cards/route.ts",
},
{
// The one definition.
code: 'export const DEFAULT_TIMEZONE = "Europe/Berlin";',
filename: "/repo/src/lib/tz/format.ts",
},
{
// Fixtures pick a concrete zone on purpose.
code: 'const TZ = "Europe/Berlin";',
filename: "/repo/src/lib/insights/__tests__/graded-series.test.ts",
},
{
// Comments are not checked.
code: "// read on Europe/Berlin days before v1.39.3\nconst x = 1;",
filename: "/repo/src/lib/insights/graded-series.ts",
},
{
// Pending: owned by another change, switched at merge.
code: 'const DEFAULT_TZ = "Europe/Berlin";',
filename: "/repo/src/lib/medications/window-status.ts",
},
{
code: 'const other = "America/New_York";',
filename: "/repo/src/lib/insights/graded-series.ts",
},
],
invalid: [
{
code: 'const tz = user.timezone || "Europe/Berlin";',
filename: "/repo/src/app/api/insights/cards/route.ts",
errors: [{ messageId: "zoneLiteral" }],
},
{
code: 'readDayAggregates({ timeZone: "Europe/Berlin" });',
filename: "/repo/src/lib/insights/graded-series.ts",
errors: [{ messageId: "zoneLiteral" }],
},
{
code: "const label = `Date: ${todayKey} (Europe/Berlin)`;",
filename: "/repo/src/lib/ai/prompts/pulse.ts",
errors: [{ messageId: "zoneLiteral" }],
},
{
code: '<input placeholder="Europe/Berlin" />',
filename: "/repo/src/components/settings/timezone-picker.tsx",
errors: [{ messageId: "zoneLiteral" }],
},
],
});
+4
View File
@@ -10,6 +10,8 @@
* and no off-scale `5` step on a bg-card shell.
* - `job-handler-outcome` — pg-boss bindings route through createAndWork,
* whose handler returns a JobOutcome.
* - `no-default-zone-literal` — the default zone is DEFAULT_TIMEZONE from
* src/lib/tz/format, never written out.
*/
"use strict";
@@ -20,6 +22,7 @@ const apiFetchRequired = require("./api-fetch-required.js");
const noRawPaletteColor = require("./no-raw-palette-color.js");
const spacingScale = require("./spacing-scale.js");
const jobHandlerOutcome = require("./job-handler-outcome.js");
const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
module.exports = {
rules: {
@@ -34,5 +37,6 @@ module.exports = {
// the semantic sweep). See the rule header.
"no-dracula-utility": noRawPaletteColor,
"job-handler-outcome": jobHandlerOutcome,
"no-default-zone-literal": noDefaultZoneLiteral,
},
};
@@ -0,0 +1,102 @@
/**
* @fileoverview ESLint rule — the default zone is named in one place.
*
* `"Europe/Berlin"` was written out in some eighty places: fallbacks for a
* user with no zone, day keys cut in Berlin whatever the user's zone, a
* graded series folded on Berlin days, a prompt that labelled every date as
* Berlin's. Each copy was a place where a user outside that zone got
* someone else's calendar. `DEFAULT_TIMEZONE` in `src/lib/tz/format.ts` is
* the one definition; everything else imports it, and a surface that means
* the user's own zone asks for it (`resolveUserTimezone`).
*
* The rule flags a string literal or template element containing
* `Europe/Berlin` in `src/`, outside `src/lib/tz/` and outside test files
* (fixtures pick concrete zones on purpose). Comments are not checked.
*
* WHAT THIS RULE DOES NOT CATCH
*
* A zone assembled from parts (`"Europe/" + city`), a different hard-coded
* zone, or a Berlin day computed through an `Intl.DateTimeFormat` built from
* a variable. It removes the literal, not every way to hard-code a calendar.
*
* `PENDING` lists files another change owns at the moment; they switch to the
* constant when that change lands, and the list shrinks to nothing.
*/
"use strict";
const ZONE = "Europe/Berlin";
const EXEMPT_ROOTS = ["src/lib/tz/"];
// Owned by the medication-scheduling change in flight; switched at merge.
const PENDING = [
"src/lib/medications/",
"src/lib/jobs/measurement-reminder.ts",
"src/lib/measurement-reminders/",
"src/app/api/measurement-reminders/",
];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!posix.includes("/src/") && !posix.startsWith("src/")) return false;
if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false;
if (PENDING.some((p) => posix.includes(p))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const noDefaultZoneLiteralRule = {
meta: {
type: "problem",
docs: {
description:
"Import DEFAULT_TIMEZONE from @/lib/tz/format instead of writing the zone out.",
},
schema: [],
messages: {
zoneLiteral:
'Do not write "Europe/Berlin" out. Import DEFAULT_TIMEZONE from "@/lib/tz/format" for a fallback, or use the user\'s own zone (resolveUserTimezone / the profile timezone) for a day boundary.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
return {
Literal(node) {
if (typeof node.value === "string" && node.value.includes(ZONE)) {
context.report({ node, messageId: "zoneLiteral" });
}
},
TemplateElement(node) {
const raw = node.value && (node.value.cooked ?? node.value.raw);
if (typeof raw === "string" && raw.includes(ZONE)) {
context.report({ node, messageId: "zoneLiteral" });
}
},
JSXText(node) {
if (typeof node.value === "string" && node.value.includes(ZONE)) {
context.report({ node, messageId: "zoneLiteral" });
}
},
};
},
};
module.exports = noDefaultZoneLiteralRule;
+4
View File
@@ -104,6 +104,10 @@ const eslintConfig = defineConfig([
// catches the omission only — a handler that returns `{ ok: true }`
// after swallowing still passes, and its header says so.
"healthlog/job-handler-outcome": "error",
// The default zone is named once, in src/lib/tz/format.ts. A copy of the
// literal anywhere else is where a user outside that zone got someone
// else's calendar. src/lib/tz/ and test files are exempt; see the rule.
"healthlog/no-default-zone-literal": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
@@ -259,18 +259,10 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
file: "src/components/measurement-reminders/vorsorge-section.tsx",
why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.",
},
{
file: "src/lib/gamification/achievements.ts",
why: "The day key streak arithmetic counts on.",
},
{
file: "src/lib/charts/bucket-time-series.ts",
why: "Bucket boundary arithmetic. The bucket LABELS are a separate step and go through `makeBucketLabelFormatters`.",
},
{
file: "src/lib/ai/prompts/insight-generator.ts",
why: "An ISO day inside a model prompt. The model reads ISO; the user never sees this string.",
},
{
file: "src/lib/jobs/measurement-reminder.ts",
why: "The local day in the notification dedup key. Changing it with a display preference would re-send a claimed reminder.",
@@ -283,10 +275,6 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
file: "src/lib/i18n/relative-time.ts",
why: "The en-CA day keys that decide today / yesterday. A comparison between two keys, never printed.",
},
{
file: "src/lib/tz/resolver.ts",
why: "The Berlin day formatter the zone arithmetic itself is built on.",
},
];
/**
@@ -0,0 +1,171 @@
/**
* Every place that writes `Measurement` rows either runs the shared
* post-write tail or says here why it does not.
*
* Two readers hang off every measurement write: the rollup buckets the write
* dirtied, and the cached status assessments of the types it touched. The
* shared tail (`afterMeasurementMutation`) runs both. Several write paths
* refreshed the rollups by hand and never re-warmed the assessments (the MCP
* writes, the Telegram reply, both importers), so a reading logged there kept
* the day's old assessment until the nightly pass. This guard freezes the set
* of write sites: a new one fails until it is listed with its tail.
*
* A tripwire, not a proof: it cannot tell whether a listed reason is still
* true, only that the set did not change without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* `prisma.measurement.create(`, `tx.measurement.deleteMany(`, … Whitespace
* and line breaks between the parts are tolerated so a formatter reflow does
* not hide a site.
*/
const WRITE_RE =
/\.\s*measurement\s*\.\s*(?:create|createMany|createManyAndReturn|upsert|update|updateMany|delete|deleteMany)\s*\(/g;
/** Runs `afterMeasurementMutation` on the rows it wrote. */
const TAIL = "runs afterMeasurementMutation";
/**
* Every file with a measurement write, and how it keeps the two readers
* current. A file listed here must still contain a write (a stale entry
* fails too).
*/
const WRITE_SITES: Record<string, string> = {
"app/api/import/csv/route.ts": TAIL,
"app/api/import/route.ts": TAIL,
"app/api/measurements/[id]/route.ts": TAIL,
"app/api/measurements/bulk-delete/route.ts": TAIL,
"app/api/measurements/by-external-ids/route.ts": TAIL,
"app/api/measurements/restore/route.ts": TAIL,
"app/api/measurements/route.ts": TAIL,
"app/api/mental-health/assessments/route.ts": TAIL,
"lib/insights/score-row.ts": TAIL,
"lib/mcp/writes.ts": TAIL,
"lib/measurements/create-from-telegram.ts": TAIL,
"lib/whoop/sync-body.ts": TAIL,
"lib/whoop/webhook-handler.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs both legs itself at the end of the sync.
"lib/fitbit/sync-core.ts":
"refolds the touched days and re-warms the touched types at the end of the upsert",
"lib/google-health/sync-core.ts":
"refolds the touched days and re-warms the touched types at the end of the upsert",
"lib/nightscout/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/withings/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/withings/sync-activity.ts":
"refolds the touched days (and the day a moved row left) and re-warms the touched types",
"lib/withings/sync-ecg.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
// The reconcile primitive writes one row inside the caller's transaction;
// its callers (Polar, Oura, WHOOP, the Apple export import) collect the
// verdicts' dirty identities and run the tail once per batch.
"lib/measurements/reconcile-external-measurement.ts":
"a primitive; every caller runs the tail over the verdicts' dirty identities",
"lib/measurements/import-apple-health-export.ts":
"the import worker refolds the import's span and re-warms every imported type",
"lib/export/restore-backup.ts":
"a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
// Value-preserving reshapes: the same readings folded into daily or hourly
// rows. The rollups are refolded; the figures an assessment reads do not
// change, so re-warming would only re-bill the same text.
"lib/measurements/consolidate-daily-mean.ts":
"value-preserving consolidation; refolds the touched days",
"lib/measurements/consolidate-legacy-steps.ts":
"value-preserving consolidation; refolds the touched days",
"lib/measurements/drain-per-sample-cumulative.ts":
"value-preserving consolidation; refolds the touched days",
"lib/measurements/dense-intraday-hourly-rebuild.ts":
"value-preserving hourly fold; refolds the touched spans",
"lib/measurements/dense-intraday-retention.ts":
"value-preserving retention fold; refolds the touched days",
"lib/jobs/sleep-timeline-backfill.ts":
"one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days",
"lib/jobs/step-consolidation-repair.ts":
"one-shot restore of step rows an old consolidation removed; refolds the touched days",
"lib/sleep/sweep-stale-segments.ts":
"tombstones re-scored sleep segments inside a sync; the calling sync refolds the night",
// Writes that do not change a reading.
"lib/jobs/note-encryption-backfill.ts":
"encrypts the note column in place; no value, instant or type changes",
"lib/jobs/measurement-tombstone-cleanup.ts":
"hard-deletes rows that were already soft-deleted and read nowhere",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function writeCounts(): Map<string, number> {
const out = new Map<string, number>();
for (const rel of sourceFiles()) {
const matches = readFileSync(join(SRC, rel), "utf8").match(WRITE_RE);
if (matches && matches.length > 0) out.set(rel, matches.length);
}
return out;
}
describe("measurement write tail", () => {
const counts = writeCounts();
it("finds the write sites at all", () => {
// An empty match set would agree with any allowlist.
expect(counts.size).toBeGreaterThanOrEqual(30);
});
it("lists every file that writes measurements", () => {
const unlisted = [...counts.keys()].filter((f) => !(f in WRITE_SITES));
expect(
unlisted,
"a new measurement write site: run afterMeasurementMutation on the rows it writes, or list it here with the reason it does not",
).toEqual([]);
});
it("has no stale entries", () => {
const stale = Object.keys(WRITE_SITES).filter((f) => !counts.has(f));
expect(stale).toEqual([]);
});
it("the files that claim the shared tail call it", () => {
const missing = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason === TAIL)
.map(([file]) => file)
.filter(
(file) =>
!/afterMeasurementMutation\s*\(/.test(
readFileSync(join(SRC, file), "utf8"),
),
);
expect(missing).toEqual([]);
});
it("the syncs that run both legs by hand call both", () => {
const handRolled = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason.includes("re-warms"))
.map(([file]) => file)
.filter(
(file) => file !== "lib/measurements/import-apple-health-export.ts",
);
expect(handRolled.length).toBeGreaterThan(0);
const missing = handRolled.filter((file) => {
const src = readFileSync(join(SRC, file), "utf8");
return !(
/recomputeBucketsForMeasurement|afterMeasurementMutation/.test(src) &&
/invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src)
);
});
expect(missing).toEqual([]);
});
});
@@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -116,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let notificationsSent = 0;
for (const med of medications) {
const userTz = med.user.timezone || "Europe/Berlin";
const userTz = med.user.timezone || DEFAULT_TIMEZONE;
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
now,
userTz,
@@ -44,8 +44,7 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
async function postAction(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
+1 -1
View File
@@ -47,8 +47,8 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const DEFAULT_TIMEZONE = "Europe/Berlin";
const CHECKUP_NOTIFY_HOUR = 9;
async function postAction(request: NextRequest): Promise<Response> {
+2 -1
View File
@@ -39,7 +39,8 @@ import {
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
+1 -1
View File
@@ -28,7 +28,7 @@ import {
ensureCycleForBleedingDay,
removeCycleStartedOn,
} from "@/lib/cycle/cycle-boundaries";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
+1 -1
View File
@@ -40,7 +40,7 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
+1 -1
View File
@@ -40,7 +40,7 @@ import {
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
+4 -1
View File
@@ -43,7 +43,8 @@ import {
type SymptomDay,
} from "@/lib/cycle/symptom-phase";
import { addDays } from "@/lib/cycle/day-math";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** Trailing window the phase-contrast walks (days). */
const WINDOW_DAYS = 365;
@@ -228,6 +229,7 @@ export const GET = apiHandler(async () => {
phaseByDay,
measurements,
userPriorityJson,
timeZone: tz,
});
const headline = selectHeadlinePhaseRow(rows);
// The lagged rows are served with their narrated `interpretation` intact, so
@@ -239,6 +241,7 @@ export const GET = apiHandler(async () => {
phaseByDay,
measurements,
userPriorityJson,
timeZone: tz,
locale,
});
+1 -1
View File
@@ -37,7 +37,7 @@ import {
toMenstrualCycleDTO,
dayLogSymptomInclude,
} from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
+1 -47
View File
@@ -67,6 +67,7 @@ import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { validTimezoneOr } from "@/lib/tz/format";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import { computeStreak } from "@/lib/dashboard/streak";
import {
readLatestEver,
readSparkBuckets,
@@ -256,53 +257,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
interface StreakInfo {
currentDays: number;
longest: number;
}
/** Compute the current logging-day streak (days where any measurement or
* intake event was recorded, in the user's display timezone) plus the
* longest streak in the last `STREAK_WINDOW_DAYS` days.
*
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
* Auckland user gets their Auckland-day streak rather than the Berlin
* one. The activity-day Set entries are already produced in the same
* zone by the caller (via `userDayKey`), so the cursor walk only needs
* the same zone here to align. */
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
let longest = 1;
let run = 1;
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
if (cur - prev === 86_400_000) {
run += 1;
longest = Math.max(longest, run);
} else {
run = 1;
}
}
// Current streak: walk back from today (user's tz).
const todayKey = userDayKey(new Date(), userTz);
let currentDays = 0;
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
// Allow yesterday's last day to count if today not yet logged.
if (!activityDays.has(userDayKey(cursor, userTz))) {
cursor = new Date(cursor.getTime() - 86_400_000);
}
while (activityDays.has(userDayKey(cursor, userTz))) {
currentDays += 1;
cursor = new Date(cursor.getTime() - 86_400_000);
}
return { currentDays, longest };
}
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
* The route only needs YYYY-MM-DD strings, so the aggregate runs
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
@@ -22,7 +22,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -34,7 +34,7 @@ import {
} from "@/lib/validations/illness";
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
+5 -22
View File
@@ -11,10 +11,7 @@ import {
parseCsvMeasurements,
type CsvRowResult,
} from "@/lib/import/csv-measurements";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type {
MeasurementType,
GlucoseContext,
@@ -317,24 +314,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
// Best-effort: a populator hiccup never fails the importer.
if (touchedMeasurements.length > 0) {
try {
const keys = collapseToTypeDayKeys(touchedMeasurements);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
} catch (err) {
annotate({
meta: {
measurement_rollup_csv_import_failed: true,
measurement_rollup_csv_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks — and
// the status re-warm for the touched types. Best-effort: a populator
// hiccup never fails the importer.
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
await auditLog("import.csv.upload", {
userId,
+8 -26
View File
@@ -32,15 +32,13 @@ import {
} from "@/lib/validations/external-id";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1 } from "@/lib/mood/level-a";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
@@ -481,27 +479,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// v1.4.39.1 — refresh the persistent measurement rollup table for
// every distinct (type, day) the import touched. Collapsed so a
// 10 000-row CSV restore pays at most ~N (type, day) recomputes
// rather than 10 000 per-row hooks. Best-effort: a populator hiccup
// never fails the importer.
if (touchedMeasurements.length > 0) {
try {
const keys = collapseToTypeDayKeys(touchedMeasurements);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
} catch (err) {
annotate({
meta: {
measurement_rollup_import_failed: true,
measurement_rollup_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// Refresh the persistent measurement rollup table for every distinct
// (type, day) the import touched (collapsed, so a 10 000-row restore pays
// at most ~N recomputes) and re-warm the touched types' status
// assessments. Best-effort: a populator hiccup never fails the importer.
await afterMeasurementMutation(userId, touchedMeasurements, "import");
annotate({
meta: {
+2 -1
View File
@@ -34,6 +34,7 @@ import {
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type Severity = "alert" | "caution" | "info" | "good";
@@ -216,7 +217,7 @@ export const GET = apiHandler(async () => {
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
const tz = user.timezone || "Europe/Berlin";
const tz = user.timezone || DEFAULT_TIMEZONE;
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const mapped = events.map((e) => ({
+3 -3
View File
@@ -41,7 +41,7 @@ import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -125,7 +125,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// pass in this route (resting-pulse proxy, medication-continuity, BP
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
// call site.
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Fetch user profile (height + DOB drive BMI + BP targets).
const dbUser = await prisma.user.findUnique({
@@ -445,7 +445,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(mapped),
user.timezone || "Europe/Berlin",
user.timezone || DEFAULT_TIMEZONE,
);
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
medicationContext,
+2 -1
View File
@@ -41,6 +41,7 @@ import {
} from "@/lib/insights/correlation-patterns";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { fetchLabDraws } from "@/lib/insights/correlation-channel-series";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -115,7 +116,7 @@ async function buildCorrelationsResponse(
where: { id: userId },
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
+1
View File
@@ -692,6 +692,7 @@ export const POST = apiHandler((request: NextRequest) =>
userId,
dbUser?.displayName ?? null,
locale,
await resolveUserTimezone(userId),
);
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
+4 -3
View File
@@ -34,8 +34,8 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import {
canonicalDailyTimestamp,
dayKeyForUserTz,
localDayWindow,
} from "@/lib/measurements/drain-per-sample-cumulative";
import { localDayWindow } from "@/lib/tz/local-day";
import { withIdempotency } from "@/lib/idempotency";
import { isP2002 } from "@/lib/prisma-errors";
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
@@ -76,6 +76,7 @@ import type {
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
@@ -192,7 +193,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const tz =
user.timezone && user.timezone.length > 0
? user.timezone
: "Europe/Berlin";
: DEFAULT_TIMEZONE;
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
@@ -242,7 +243,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const tz =
user.timezone && user.timezone.length > 0
? user.timezone
: "Europe/Berlin";
: DEFAULT_TIMEZONE;
// v1.29.6 — DESC + take so a long history returns the MOST RECENT
// window of raw samples for bucketing. The previous `orderBy: asc`
// + `take: limit` sliced the OLDEST page of rows before the
@@ -9,6 +9,7 @@ import {
} from "@/lib/medications/compliance-payload";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -43,7 +44,7 @@ export const GET = apiHandler(
return apiError("Too many compliance requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Read-through the per-user compliance cache (15 min fresh TTL +
// SWR window). The ownership guard and the medication read live
@@ -41,6 +41,7 @@ import {
} from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -120,7 +121,7 @@ export const GET = apiHandler(
}
const now = new Date();
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
const medication = await prisma.medication.findUnique({
where: { id },
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -31,7 +32,7 @@ export const GET = apiHandler(
return apiError("Too many efficacy requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
const dto = await buildMedicationEfficacy(user.id, id, userTz);
if (!dto) {
return apiError("Medication not found", 404);
+3 -2
View File
@@ -47,6 +47,7 @@ import {
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -176,7 +177,7 @@ export const GET = apiHandler(
},
schedules: liveSchedules,
now,
userTz: user.timezone || "Europe/Berlin",
userTz: user.timezone || DEFAULT_TIMEZONE,
lastIntakeAt: lastIntake?.takenAt ?? null,
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
@@ -604,7 +605,7 @@ export const PUT = apiHandler(
// `syncVersion` so delta-sync clients drop them. The projector and
// the reminder worker re-mint the anchors for the new times on their
// next pass — the route does not pre-create them.
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
const tombstoneWhere = {
userId: user.id,
+2 -1
View File
@@ -12,6 +12,7 @@ import {
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* Batched card-compliance read: one round trip for every medication the
@@ -43,7 +44,7 @@ export const GET = apiHandler(async () => {
return apiError("Too many compliance requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Same ordering as the medications list so the page's card order and
// this payload walk the same sequence.
+2 -1
View File
@@ -14,7 +14,8 @@ import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
+2 -1
View File
@@ -51,7 +51,8 @@ import {
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { levelAForWrite } from "@/lib/mood/level-a";
import { persistMoodContext } from "@/lib/mood/context";
import { encryptNote } from "@/lib/crypto/note-cipher";
+2 -1
View File
@@ -24,7 +24,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { listMoodEntriesPage } from "@/lib/mood/list-read";
+1 -2
View File
@@ -38,8 +38,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveLinkedDayContext } from "@/lib/mood/linked-context";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { isValidTimezone } from "@/lib/tz/format";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
+2 -1
View File
@@ -42,6 +42,7 @@ import {
NATIVE_REFRESH_TOKEN_DAYS,
TOMBSTONE_RETENTION_DAYS,
} from "@/lib/auth/native-client";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
@@ -119,7 +120,7 @@ export const GET = apiHandler(async (_request: NextRequest) => {
return apiSuccess({
userId: user.id,
timezone: currentUser?.timezone ?? "Europe/Berlin",
timezone: currentUser?.timezone ?? DEFAULT_TIMEZONE,
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
serverNow: serverNow.toISOString(),
measurements: {
+2 -1
View File
@@ -48,6 +48,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary";
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface Schedule {
id: string;
@@ -397,7 +398,7 @@ export default function MedicationsPageClient() {
// medications past their band tail stay takeable from the batch. Page
// order is preserved so the confirm dialog lists medications as rendered.
const dueMeds = deriveDueMedications(activeMeds, {
tz: user?.timezone || "Europe/Berlin",
tz: user?.timezone || DEFAULT_TIMEZONE,
thresholds: thresholds ?? undefined,
doseStatusById: new Map(
(complianceRows ?? []).map((row) => [
+12 -24
View File
@@ -1,7 +1,12 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import { heatmapDays } from "@/lib/charts/heatmap-days";
interface DailyData {
expected: number;
@@ -79,6 +84,7 @@ export function ComplianceHeatmap({
}: ComplianceHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const timeZone = useDisplayTimezone();
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
// locale-aware renderer never lands a day off in either direction.
const formatDay = (dateKey: string) =>
@@ -175,7 +181,6 @@ export function ComplianceHeatmap({
t("charts.months.nov"),
t("charts.months.dec"),
];
const now = new Date();
const cellList: Array<{
dateKey: string;
col: number;
@@ -184,34 +189,18 @@ export function ComplianceHeatmap({
data: DailyData;
}> = [];
// Build array of dates from oldest to newest
const dates: Date[] = [];
for (let d = days - 1; d >= 0; d--) {
const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
dates.push(date);
}
// Start from the first date, align to Monday.
//
// v1.4.27 B7 / BL-P4-2 — read every weekday + month boundary off
// the UTC accessor pair (`getUTCDay`, `getUTCMonth`) so the
// computation matches the dateKey, which is also UTC-anchored
// (`toISOString().slice(0, 10)`). Reading in server-tz here would
// shift the Monday-alignment + month-marker placement when the
// SSR pass runs on a server in a non-Berlin timezone.
const firstDate = dates[0];
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
// Oldest to newest, one entry per local day in the user's zone.
const dates = heatmapDays(new Date(), timeZone, days);
const firstDow = dates[0]?.dow ?? 0;
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
const { dateKey, dow, month } = dates[i];
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
const dateKey = date.toISOString().slice(0, 10);
const data = dailyCompliance[dateKey] ?? {
expected: 0,
taken: 0,
@@ -219,7 +208,6 @@ export function ComplianceHeatmap({
};
// Track month boundaries
const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
@@ -237,7 +225,7 @@ export function ComplianceHeatmap({
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [dailyCompliance, days, t]);
}, [dailyCompliance, days, t, timeZone]);
const activeDays = cells.filter((cell) => cell.data.expected > 0).length;
const summaryLabel =
@@ -69,6 +69,7 @@ import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { apiGet } from "@/lib/api/api-fetch";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface DailyCompliancePoint {
/** Berlin calendar day, "YYYY-MM-DD". */
@@ -208,7 +209,7 @@ interface MedicationComplianceChartProps {
export function MedicationComplianceChart({
title,
userTimezone = "Europe/Berlin",
userTimezone = DEFAULT_TIMEZONE,
compareBaseline,
onDataReady,
}: MedicationComplianceChartProps) {
+11 -17
View File
@@ -1,7 +1,12 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import { heatmapDays } from "@/lib/charts/heatmap-days";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
@@ -62,6 +67,7 @@ export function MoodHeatmap({
}: MoodHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const timeZone = useDisplayTimezone();
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
// locale-aware renderer never lands a day off in either direction.
const formatDay = (dateKey: string) =>
@@ -147,7 +153,6 @@ export function MoodHeatmap({
t("charts.months.nov"),
t("charts.months.dec"),
];
const now = new Date();
const cellList: Array<{
dateKey: string;
col: number;
@@ -156,30 +161,19 @@ export function MoodHeatmap({
cell: MoodHeatmapCell | null;
}> = [];
const dates: Date[] = [];
for (let d = days - 1; d >= 0; d--) {
const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
dates.push(date);
}
// Monday-align + month markers read off the UTC accessors so the
// grid matches the UTC-anchored dateKey (mirrors compliance-heatmap).
const firstDate = dates[0];
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
const dates = heatmapDays(new Date(), timeZone, days);
const firstDow = dates[0]?.dow ?? 0;
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
const { dateKey, dow, month } = dates[i];
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
const dateKey = date.toISOString().slice(0, 10);
const cell = cellData[dateKey] ?? null;
const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
@@ -197,7 +191,7 @@ export function MoodHeatmap({
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [cellData, days, t]);
}, [cellData, days, t, timeZone]);
const loggedDays = cells.filter((cell) => cell.cell !== null).length;
const summaryLabel =
@@ -31,6 +31,7 @@ import {
intakeToastOptions,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
@@ -115,7 +116,7 @@ export function MedicationIntakeQuickAdd({
// source the cards use); Berlin stays the last-resort fallback so
// logged-out mounts behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const userTz = user?.timezone || DEFAULT_TIMEZONE;
// The record this dose lands in, or null in the caller's own.
const recordName = useActiveRecordName();
@@ -19,7 +19,7 @@ import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -94,7 +94,7 @@ export function IntradayPulseChart({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const tz = userTimezone ?? "Europe/Berlin";
const tz = userTimezone ?? DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), tz);
// `null` tracks "today" live (so a session left open across midnight keeps
// following the current day); a non-null value pins the viewed day once
@@ -34,7 +34,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
}));
import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section";
import { DISPLAY_TIMEZONE } from "@/lib/format-locale";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* Noon on the day `offsetDays` from today, counted on the DISPLAY zone's
@@ -53,7 +53,7 @@ import { DISPLAY_TIMEZONE } from "@/lib/format-locale";
function noonOnDisplayDay(offsetDays: number): Date {
// `en-CA` formats as YYYY-MM-DD, which is the shape we want to add days to.
const today = new Intl.DateTimeFormat("en-CA", {
timeZone: DISPLAY_TIMEZONE,
timeZone: DEFAULT_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -219,7 +219,7 @@ describe("<VorsorgeSection> loading + empty", () => {
// the wall-clock gap to now exceeds the 24h a rolling delta would key on.
// Anchor the due instant to noon on the DISPLAY zone's today, not the
// process clock's. `vitest.config.mts` pins the suite to TZ=UTC while the
// component floors in the profile zone (`DISPLAY_TIMEZONE`, Europe/Berlin),
// component floors in the profile zone (`DEFAULT_TIMEZONE`, Europe/Berlin),
// and those two disagree about the date for the two hours before UTC
// midnight. Anchoring on `setHours` made this case fail every night in
// that window, which is the same confusion of clocks the assertion exists
@@ -1,3 +1,4 @@
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.12.3 — resolve the canonical slot instant of the dose a medication
* card is currently surfacing, so the card's "Genommen" / "Skip" buttons
@@ -138,7 +139,7 @@ export function resolveDisplayedSlotInstant(
currentWindowStatus,
nextDueAt,
now,
timeZone = "Europe/Berlin",
timeZone = DEFAULT_TIMEZONE,
} = input;
// The card surfaces the current/overdue dose band — record that band's
@@ -111,6 +111,7 @@ import {
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -182,7 +183,7 @@ export function DoseHistoryLedger({
// marked — the most-encountered of the set.
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
const timeZone = user?.timezone || DEFAULT_TIMEZONE;
const [addOpen, setAddOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<{
@@ -37,6 +37,7 @@ import {
} from "@/components/medications/detail/supply-runway";
import { useAuth } from "@/hooks/use-auth";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
@@ -202,7 +203,7 @@ export function Glp1MedicationCard({
// v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the
// last-resort fallback for logged-out mounts and legacy fixtures.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const fmt = useFormatters();
// Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The
// weekday name paired with `fmt.dateShort` must be derived in this SAME
@@ -44,6 +44,7 @@ import {
runLogIntake,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface LogIntakeSchedule extends ScheduleWindowInput {
label: string | null;
@@ -120,7 +121,7 @@ export function LogIntakeDialog({
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const formId = useId();
const [medicationOverride, setMedicationOverride] = useState<string | null>(
@@ -34,6 +34,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import { useAuth } from "@/hooks/use-auth";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface Schedule {
id: string;
@@ -149,7 +150,7 @@ export function MedicationCard({
// session carries); Berlin stays the last-resort fallback so logged-out
// mounts and legacy fixtures behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
@@ -43,6 +43,7 @@ import {
} from "@/lib/queries/use-medication-compliance-summary";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.16.10 — the compact table view for /medications (issue #316 item 3).
@@ -194,7 +195,7 @@ export function MedicationTable({
}: MedicationTableProps) {
const { t, locale } = useTranslations();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const [sort, setSort] = useState<MedicationTableSort | null>(initialSort);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
+2 -1
View File
@@ -57,6 +57,7 @@ import {
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import type { DoseStatus } from "@/lib/analytics/compliance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type Translator = (
key: string,
@@ -142,7 +143,7 @@ export function deriveDueMedications(
} = {},
): DueMedication[] {
const now = options.now ?? new Date();
const tz = options.tz ?? "Europe/Berlin";
const tz = options.tz ?? DEFAULT_TIMEZONE;
const { lateMinutes, missedMinutes } =
options.thresholds ?? DEFAULT_THRESHOLDS;
@@ -33,7 +33,7 @@ import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
@@ -74,7 +74,7 @@ export function AccountSection() {
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>("Europe/Berlin");
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
// v1.7.0 — optional patient-identity fields for the health-record export.
const [fullName, setFullName] = useState("");
const [insurerName, setInsurerName] = useState("");
@@ -84,7 +84,7 @@ export function AccountSection() {
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
timezone: "Europe/Berlin",
timezone: DEFAULT_TIMEZONE,
fullName: "",
insurerName: "",
insuranceNumber: "",
@@ -39,6 +39,7 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "./_card-actions";
import { SettingsCardHeader } from "./_card-header";
import { SettingsCard } from "./settings-card";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface ManagedRecordSettingsResponse {
recordId: string;
@@ -414,7 +415,7 @@ function ProfileSettingsForm({
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
{t("settings.sharedRecord.managedSettings.profile.timezone")}
<Input
defaultValue={asString(settings.timezone, "Europe/Berlin")}
defaultValue={asString(settings.timezone, DEFAULT_TIMEZONE)}
disabled={disabled}
id="managed-timezone"
name="timezone"
+2 -2
View File
@@ -26,7 +26,7 @@ import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { listSupportedTimezones } from "@/lib/tz/format";
import { listSupportedTimezones, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { useTranslations } from "@/lib/i18n/context";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
@@ -67,7 +67,7 @@ export function TimezonePicker({
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Europe/Berlin"
placeholder={DEFAULT_TIMEZONE}
autoComplete="off"
/>
<p className="text-muted-foreground text-xs">{labelHint}</p>
+6 -3
View File
@@ -41,7 +41,8 @@ import {
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
const WINDOW_DAYS = 365;
@@ -200,12 +201,13 @@ export async function buildCycleSnapshotBlock(
// many readings to hold as objects (#1023). The crosstab only needs
// each day's sum and count per source and device, which is what the
// per-source picker decides on, so the fold runs in SQL. Days are the
// Berlin days `metricDayMap` keys on.
// user's days, the ones `metricDayMap` keys on and the phase days are
// dated in.
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: new Date(Date.parse(`${from}T00:00:00Z`)),
timeZone: "Europe/Berlin",
timeZone: tz,
}),
prisma.user.findUnique({
where: { id: userId },
@@ -292,6 +294,7 @@ export async function buildCycleSnapshotBlock(
phaseByDay,
measurements,
userPriorityJson: userRow?.sourcePriorityJson ?? null,
timeZone: tz,
}),
);
+1 -1
View File
@@ -17,7 +17,7 @@
import { prisma } from "@/lib/db";
import { isIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
+3
View File
@@ -206,6 +206,8 @@ export async function buildCoarseTimelineTail(
type: MeasurementType,
now: Date,
tz: string,
/** The user's source-priority blob, already loaded by the caller. */
userPriorityJson: unknown,
): Promise<CoarseTimelineTail | undefined> {
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
@@ -222,6 +224,7 @@ export async function buildCoarseTimelineTail(
now: now.getTime(),
tz,
coarseOnly: true,
userPriorityJson,
});
} catch {
return undefined;
+3
View File
@@ -548,6 +548,7 @@ async function buildCoachSnapshotImpl(
"BLOOD_PRESSURE_SYS" as MeasurementType,
now,
userTz,
prefsRow?.sourcePriorityJson ?? null,
);
}
if (wantsWeight) {
@@ -556,6 +557,7 @@ async function buildCoachSnapshotImpl(
"WEIGHT" as MeasurementType,
now,
userTz,
prefsRow?.sourcePriorityJson ?? null,
);
}
if (wantsPulse) {
@@ -564,6 +566,7 @@ async function buildCoachSnapshotImpl(
"PULSE" as MeasurementType,
now,
userTz,
prefsRow?.sourcePriorityJson ?? null,
);
}
const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
+2 -1
View File
@@ -56,6 +56,7 @@ import {
loadBaselineProfile,
isDerivedOk,
} from "@/lib/insights/derived";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Trailing window for the discovery scan — mirrors the insight route. */
@@ -209,7 +210,7 @@ export async function readCoachCorrelations(
where: { id: userId },
select: { timezone: true },
});
const tz = userRow?.timezone ?? "Europe/Berlin";
const tz = userRow?.timezone ?? DEFAULT_TIMEZONE;
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
// The channel set is the shared one, so the Coach scans exactly what the
+2 -1
View File
@@ -22,6 +22,7 @@ import {
buildWorkoutEvidence,
type WorkoutEvidenceInput,
} from "./workout-evidence";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface WorkoutEvidenceRow {
sportType: string;
@@ -108,7 +109,7 @@ export function createWorkoutEvidenceBuilder(
sportType: row.sportType,
source: row.source,
startedAt: row.startedAt,
timezone: profile?.timezone ?? "Europe/Berlin",
timezone: profile?.timezone ?? DEFAULT_TIMEZONE,
durationSec: row.durationSec,
totalEnergyKcal: row.totalEnergyKcal,
totalDistanceM: row.totalDistanceM,
@@ -15,6 +15,7 @@ describe("buildBriefingPersonalisationBlock", () => {
"user-1",
null,
"en",
"UTC",
day("2026-06-27T08:00:00Z"),
);
expect(en).toContain("OPENER HINT:");
@@ -22,6 +23,7 @@ describe("buildBriefingPersonalisationBlock", () => {
"user-1",
null,
"de",
"UTC",
day("2026-06-27T08:00:00Z"),
);
expect(de).toContain("OPENER-HINWEIS:");
@@ -33,6 +35,7 @@ describe("buildBriefingPersonalisationBlock", () => {
"user-1",
null,
"en",
"UTC",
day(`2026-06-${String(d).padStart(2, "0")}T08:00:00Z`),
);
expect(out).not.toContain("NAME:");
@@ -47,6 +50,7 @@ describe("buildBriefingPersonalisationBlock", () => {
"user-42",
"Alex Rivera",
"en",
"UTC",
day(`2026-06-${String(d).padStart(2, "0")}T08:00:00Z`),
);
if (out.includes('"Alex"')) withName += 1;
@@ -62,6 +66,7 @@ describe("buildBriefingPersonalisationBlock", () => {
"user-42",
"Alex Rivera",
"en",
"UTC",
day("2026-06-30T08:00:00Z"),
);
expect(out).not.toContain("Rivera");
+2 -2
View File
@@ -68,12 +68,12 @@ export function getBloodPressureUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's blood pressure. Open with what the reading MEANS in plain words — the overall read, not the number (e.g. "calm and right where you want it", "running a touch higher than your usual this week") — then bring in the systolic/diastolic figures right after as support, judged as ONE pair and placed against their own weekly/monthly baseline; never lead with the numbers. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zum Blutdruck dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — dem Gesamteindruck, nicht der Zahl (z. B. "ruhig und genau da, wo du ihn haben willst", "diese Woche einen Tick höher als sonst") — und bring danach die systolischen/diastolischen Werte als Beleg, als EIN Paar beurteilt und gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit den Werten. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
+2 -2
View File
@@ -55,12 +55,12 @@ export function getBmiUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's BMI. Open with what its band placement MEANS in plain words — where it sits and whether that's holding or shifting, not the number (e.g. "sitting comfortably in the healthy band", "edging toward the next band up") — then bring in the current value and WHO band right after as support, saying against their own weekly/monthly baseline whether it has crossed a band or is nearing a boundary; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zum BMI dieser Person. Beginne mit der BEDEUTUNG der Bandlage in klaren Worten — wo er liegt und ob das hält oder sich verschiebt, nicht der Zahl (z. B. "liegt bequem im gesunden Band", "nähert sich dem nächsten Band") — und bring danach den aktuellen Wert und das WHO-Band als Beleg, samt Angabe gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert; führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
+2 -2
View File
@@ -63,12 +63,12 @@ export function getGeneralStatusUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short overall assessment across the available health metrics. Open with the overall read in plain words — how things are looking taken together, not a number (e.g. "a steady stretch across the board", "one thing standing out this week") — then bring in the one or two metrics that stand out as support, each placed against the person's own weekly/monthly baseline; never lead with a value. Close with the single most important doable step only when the overall picture genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten. Beginne mit dem Gesamteindruck in klaren Worten — wie es zusammengenommen aussieht, nicht mit einer Zahl (z. B. "über alles hinweg eine ruhige Phase", "diese Woche sticht eine Sache heraus") — und bring danach die ein bis zwei auffälligsten Metriken als Beleg, jeweils gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit einem Wert. Schließe nur dann mit dem einen wichtigsten machbaren Schritt, wenn das Gesamtbild wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
+4 -11
View File
@@ -47,6 +47,7 @@ import {
outlookContract,
formattingContract,
} from "./shared-contracts";
import { userDayKey } from "@/lib/tz/format";
/**
* Stable identifier for the active system prompt revision.
@@ -63,16 +64,6 @@ import {
*/
export const PROMPT_VERSION = "5.1.0" as const;
/** Europe/Berlin YYYY-MM-DD day key — the rotation boundary for the opener. */
function berlinDayKey(now: Date): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
/**
* v1.22 (W6) — the daily-briefing personalization SYSTEM CONTEXT block.
*
@@ -92,9 +83,11 @@ export function buildBriefingPersonalisationBlock(
userId: string,
displayName: string | null,
locale: Locale,
/** The user's zone: the opener rotates at the user's own midnight. */
timeZone: string,
now: Date = new Date(),
): string {
const dayKey = berlinDayKey(now);
const dayKey = userDayKey(now, timeZone);
const hint = openerArchetypeHint(`${userId}:briefing:${dayKey}`, locale);
const firstName = firstNameFromDisplayName(displayName);
const useName =
+2 -2
View File
@@ -57,12 +57,12 @@ export function getMedicationComplianceUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's medication adherence. Open with what it MEANS in plain words — how reliable the routine is looking, not the number (e.g. "your routine's been rock-solid", "a few doses have slipped lately") — then bring in the recent rate right after as support, placed against their own baseline (compliance7 vs compliance30); never lead with the value. Close with one doable, blame-free step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — wie verlässlich die Routine aussieht, nicht der Zahl (z. B. "deine Routine sitzt richtig gut", "zuletzt sind ein paar Dosen durchgerutscht") — und bring danach die jüngste Rate als Beleg, gegen die eigene Baseline eingeordnet (compliance7 vs. compliance30); führe nie mit dem Wert. Schließe nur dann mit einem machbaren, wertfreien Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
+2 -2
View File
@@ -165,12 +165,12 @@ export function getMetricArchetypeUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's ${meta.displayName.toLowerCase()}. Open with what it MEANS in plain words — the read, not the number (e.g. "running a little calmer than usual", "steady and right where it's been") — then bring in ONE concrete number from the snapshot right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}${interpBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — dem Eindruck, nicht der Zahl (z. B. "läuft gerade etwas ruhiger als sonst", "stabil und genau da, wo es war") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}${interpBlock}
${snapshotJson}`;
+2 -2
View File
@@ -57,12 +57,12 @@ export function getMoodUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's mood. Open with what the recent pattern MEANS in plain words — the read, not the number (e.g. "a brighter stretch than usual", "steady, holding where it's been") — then bring in the recent level right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one kind, doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zur Stimmung dieser Person. Beginne mit der BEDEUTUNG des jüngsten Musters in klaren Worten — dem Eindruck, nicht der Zahl (z. B. "eine hellere Phase als sonst", "stabil, hält sich, wo sie war") — und bring danach das jüngste Niveau als Beleg, gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit dem Wert. Schließe nur dann mit einem freundlichen, machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
+2 -2
View File
@@ -55,12 +55,12 @@ export function getPulseUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's resting pulse. Open with what it MEANS in plain words — the read, not the number (e.g. "running a little calmer than usual", "steady, right where it's been") — then bring in ONE concrete number from the snapshot right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — dem Eindruck, nicht der Zahl (z. B. "läuft gerade etwas ruhiger als sonst", "stabil, genau da, wo er war") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
+2 -2
View File
@@ -63,12 +63,12 @@ export function getWeightUserPrompt(
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
return `Date: ${todayKey}${openerLine}
Write one short assessment of this person's weight. Open with what the trend MEANS in plain words — the direction and momentum, not the number (e.g. "easing down steadily", "holding right where it's settled") — then bring in ONE concrete number from the snapshot right after as support, read as a continuous trend and pace against their own weekly/monthly baseline (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers); never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)${openerLine}
return `Datum: ${todayKey}${openerLine}
Schreibe eine kurze Einschätzung zum Gewicht dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — Richtung und Tempo, nicht der Zahl (z. B. "geht ruhig nach unten", "hält sich genau da, wo es sich eingependelt hat") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, als kontinuierlichen Trend und Tempo gegen die eigene Wochen-/Monats-Baseline gelesen (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt); führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
@@ -29,10 +29,9 @@ import {
resolveRestingPulseSeries,
type PulseSample,
} from "@/lib/analytics/resting-pulse";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { percentile } from "@/lib/insights/strain-score";
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
import { localDayWindow } from "@/lib/tz/local-day";
/** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */
function median(values: number[]): number | null {
@@ -96,7 +95,7 @@ describe("loadIntradayPulse", () => {
// Sanity: under the OLD hardcoded-Berlin bug all six samples fall on the
// SAME Berlin day — proving the two tz groupings genuinely diverge.
const berlinDays = new Set(
[...dayA, ...dayB].map((d) => toBerlinDayKey(d)),
[...dayA, ...dayB].map((d) => userDayKey(d, "Europe/Berlin")),
);
expect(berlinDays.size).toBe(1);
@@ -151,7 +150,7 @@ describe("loadIntradayPulse", () => {
const buggyBerlin = resolveRestingPulseSeries({
restingSamples: [],
pulseSamples: pulseHistory as PulseSample[],
dayKeyOf: (d) => toBerlinDayKey(d),
dayKeyOf: (d) => userDayKey(d, "Europe/Berlin"),
});
const buggyBaseline = median(
buggyBerlin.series.slice(-30).map((p) => p.value),
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
readRollupBuckets: vi.fn(),
findMany: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: mocks.findMany } },
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
readRollupBuckets: mocks.readRollupBuckets,
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
aggregateWmyBuckets: vi.fn(),
readBestGranularityRollups: vi.fn(async () => null),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "UTC"),
}));
import { computeRangeDelta } from "@/lib/analytics/range-delta";
const DAY_MS = 86_400_000;
const NOW = Date.UTC(2026, 5, 30, 12);
function dayRow(daysAgo: number, total: number, samples: number) {
const bucketStart = new Date(Date.UTC(2026, 5, 30) - daysAgo * DAY_MS);
return {
bucketStart,
count: samples,
mean: total / samples,
minValue: 0,
maxValue: total,
sd: null,
slope: null,
r2: null,
computedAt: bucketStart,
};
}
beforeEach(() => {
mocks.readRollupBuckets.mockReset();
mocks.findMany.mockReset();
});
describe("computeRangeDelta — per-day and per-night metrics", () => {
it("compares average daily step totals, not the mean of the readings", async () => {
// Six complete days of 8,000 in the previous week, and in the current
// week five drained days plus today still arriving as 200 samples.
mocks.readRollupBuckets.mockResolvedValue([
...[8, 9, 10, 11, 12, 13].map((d) => dayRow(d, 8000, 1)),
...[1, 2, 3, 4, 5].map((d) => dayRow(d, 8000, 1)),
dayRow(0, 4000, 200),
]);
const result = await computeRangeDelta("u", "ACTIVITY_STEPS", "7d", NOW);
expect(result.granularity).toBe("DAY");
expect(result.previous).toMatchObject({ count: 6, mean: 8000 });
expect(result.current.count).toBe(6);
expect(result.current.mean).toBeCloseTo((5 * 8000 + 4000) / 6, 6);
expect(result.current.sum).toBeCloseTo(44_000, 6);
});
it("compares average nights of sleep, not the mean of stage rows", async () => {
const row = (iso: string, minutes: number, sleepStage: string) => ({
value: minutes,
measuredAt: new Date(iso),
sleepStage,
source: "APPLE_HEALTH",
deviceType: null,
});
// Two nights: 7 h asleep (split into three stage rows plus an in-bed
// envelope) and 6 h asleep.
mocks.findMany.mockResolvedValue([
row("2026-06-28T01:00:00.000Z", 480, "IN_BED"),
row("2026-06-28T02:00:00.000Z", 180, "CORE"),
row("2026-06-28T04:00:00.000Z", 120, "DEEP"),
row("2026-06-28T06:00:00.000Z", 120, "REM"),
row("2026-06-29T06:00:00.000Z", 360, "ASLEEP"),
]);
const result = await computeRangeDelta("u", "SLEEP_DURATION", "7d", NOW);
expect(result.granularity).toBe("live");
expect(result.current.count).toBe(2);
expect(result.current.mean).toBeCloseTo(390, 6);
});
});
@@ -13,6 +13,9 @@ import {
type PulseSample,
} from "../resting-pulse";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { userDayKey } from "@/lib/tz/format";
const DAY_KEY = (d: Date) => userDayKey(d, "Europe/Berlin");
/**
* The zone `deriveRestingProxyFromPulse` buckets in by default
@@ -69,7 +72,7 @@ describe("deriveRestingProxyFromPulse", () => {
),
);
}
const proxy = deriveRestingProxyFromPulse(samples);
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
expect(proxy).toHaveLength(1);
// 510 samples, only 10 below ~80 — but the 20th percentile of the
// whole day still lands in the workout band because 98 % of samples
@@ -94,7 +97,7 @@ describe("deriveRestingProxyFromPulse", () => {
for (let i = 0; i < 10; i++) {
samples.push(day(`2026-06-02T18:${String(i).padStart(2, "0")}:00`, 150));
}
const proxy = deriveRestingProxyFromPulse(samples);
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
expect(proxy).toHaveLength(1);
// 20th percentile sits in the low band, well below the workout reads.
expect(proxy[0].value).toBeLessThanOrEqual(80);
@@ -112,7 +115,7 @@ describe("deriveRestingProxyFromPulse", () => {
day("2026-06-01T12:00:00", 64),
day("2026-06-01T20:00:00", 68),
];
const proxy = deriveRestingProxyFromPulse(samples);
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
expect(proxy).toHaveLength(2);
expect(proxy[0].measuredAt.getTime()).toBeLessThan(
proxy[1].measuredAt.getTime(),
@@ -125,7 +128,9 @@ describe("deriveRestingProxyFromPulse", () => {
const oneSampleWorkoutDay: PulseSample[] = [
day("2026-06-05T18:00:00", 165),
];
expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay)).toEqual([]);
expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay, DAY_KEY)).toEqual(
[],
);
// A day below the min-sample floor is dropped; only the day with enough
// samples contributes.
@@ -135,13 +140,13 @@ describe("deriveRestingProxyFromPulse", () => {
day("2026-06-06T08:00:00", 62),
day("2026-06-06T09:00:00", 64),
];
const proxy = deriveRestingProxyFromPulse(mixed);
const proxy = deriveRestingProxyFromPulse(mixed, DAY_KEY);
expect(proxy).toHaveLength(1);
expect(proxy[0].value).toBeLessThan(150);
});
it("returns empty for no samples", () => {
expect(deriveRestingProxyFromPulse([])).toEqual([]);
expect(deriveRestingProxyFromPulse([], DAY_KEY)).toEqual([]);
});
});
@@ -158,6 +163,7 @@ describe("resolveRestingPulseSeries", () => {
day("2026-06-01T18:00:00", 150),
);
const { series, which } = resolveRestingPulseSeries({
dayKeyOf: DAY_KEY,
restingSamples,
pulseSamples,
});
@@ -174,6 +180,7 @@ describe("resolveRestingPulseSeries", () => {
day("2026-06-01T18:00:00", 150),
];
const { series, which } = resolveRestingPulseSeries({
dayKeyOf: DAY_KEY,
restingSamples: [],
pulseSamples,
});
@@ -184,7 +191,11 @@ describe("resolveRestingPulseSeries", () => {
it("reports 'none' when neither series has data", () => {
expect(
resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }),
resolveRestingPulseSeries({
dayKeyOf: DAY_KEY,
restingSamples: [],
pulseSamples: [],
}),
).toEqual({ series: [], which: "none" });
});
@@ -218,6 +229,7 @@ describe("resolveRestingPulseSeries", () => {
}
const { series, which } = resolveRestingPulseSeries({
dayKeyOf: DAY_KEY,
restingSamples,
pulseSamples,
});
@@ -249,6 +261,7 @@ describe("resolveRestingPulseSeries", () => {
// A native day must not be second-guessed by the proxy, and a native
// account with no gap days keeps the honest 'resting' label.
const { series, which } = resolveRestingPulseSeries({
dayKeyOf: DAY_KEY,
restingSamples: [day("2026-06-01T04:00:00", 58)],
pulseSamples: [
day("2026-06-01T07:00:00", 80),
@@ -276,7 +289,7 @@ describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", ()
day("2026-06-02T15:00:00", 68),
day("2026-06-02T21:00:00", 70),
];
const proxy = deriveRestingProxyFromPulse(samples);
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
expect(proxy).toHaveLength(1);
});
@@ -293,7 +306,7 @@ describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", ()
day("2026-06-02T00:30:00", 82),
day("2026-06-02T00:50:00", 84),
];
const proxy = deriveRestingProxyFromPulse(samples);
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
expect(proxy).toHaveLength(2);
});
@@ -412,16 +412,9 @@ describe("computeSummariesSlice", () => {
// call is the pre-fold all-time remainder splice.
expect(RAW).toHaveBeenCalledTimes(2);
expect(UNSAFE).toHaveBeenCalledTimes(3);
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
// `readBestGranularityRollups(userId, type, 395)` per
// type-with-data via `prisma.measurementRollup.findMany`. The
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
//
// v1.20.0 F6 — plus the 90-day accumulator findMany the warm path
// now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
// The 90-day accumulator findMany plus one year-ago DAY read per
// type-with-data.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
});
// Watched red: with the pre-fold splice removed from the rollup path
@@ -483,28 +476,14 @@ describe("computeSummariesSlice", () => {
});
/**
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
* `null`; we now populate it from `readBestGranularityRollups` when
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
* the `[now-395d, now-365d)` slice.
* `avg30LastYear` is the mean over the exact 30 UTC days that start 395
* days ago, read from canonical DAY buckets.
*/
describe("year-over-year wiring (avg30LastYear)", () => {
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
// Coverage probe shows WEIGHT covered → rollup happy path.
function warmPathMocks() {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]);
UNSAFE.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
{ type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82 },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
@@ -512,120 +491,59 @@ describe("computeSummariesSlice", () => {
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// v1.20.0 F6 — the warm path runs the 90-day accumulator findMany
// first; return empty (slope composes to null, irrelevant to this
// year-over-year assertion) so the year-ago MONTH probe is call #2.
// The 90-day accumulator findMany runs first; empty (slope composes
// to null, irrelevant here) so the year-ago DAY read is call #1.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
const yearAgoBucketStart = new Date(
Date.now() - 380 * 24 * 60 * 60 * 1000,
);
}
function dayBucket(daysAgo: number, count: number, mean: number) {
return {
bucketStart: startOfUtcDay(
new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000),
),
source: "MANUAL",
count,
mean,
sd: 0,
slope: null,
r2: null,
sumValue: count * mean,
minValue: mean,
maxValue: mean,
sumX: null,
sumXy: null,
sumXx: null,
sumYy: null,
computedAt: new Date(),
};
}
it("reads exactly the 30 days that start 395 days ago", async () => {
warmPathMocks();
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: yearAgoBucketStart,
count: 10,
mean: 85,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 83,
maxValue: 87,
},
dayBucket(390, 10, 85),
dayBucket(370, 30, 81),
]);
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR). Call #0
// is the v1.20.0 F6 accumulator findMany (DAY); the WMY probe is #1.
expect(ROLLUP_FIND_MANY.mock.calls[1][0].where.granularity).toBe("MONTH");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(82);
const where = ROLLUP_FIND_MANY.mock.calls[1][0].where;
expect(where.granularity).toBe("DAY");
const DAY = 24 * 60 * 60 * 1000;
expect(where.bucketStart).toEqual({
gte: startOfUtcDay(new Date(Date.now() - 395 * DAY)),
lt: startOfUtcDay(new Date(Date.now() - 365 * DAY)),
});
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]);
UNSAFE.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// v1.20.0 F6 — accumulator findMany runs first; empty so the WMY
// probe is call #2.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: recentBucket,
count: 10,
mean: 82,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 80,
maxValue: 84,
},
]);
const result = await computeSummariesSlice("user-recent");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
});
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]);
UNSAFE.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Default mock returns `[]` for every findMany call → router
// walks YEAR → MONTH → WEEK → DAY and gives up.
it("leaves avg30LastYear null when no reading falls in the window", async () => {
warmPathMocks();
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeSummariesSlice("user-empty-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss; plus the
// v1.20.0 F6 accumulator findMany = 4.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
});
it("only probes types that actually have data in the current window", async () => {
@@ -11,23 +11,30 @@
* This test mirrors the v1.4.40 W-POOL pin
* (`src/app/api/analytics/__tests__/route.test.ts` —
* "pin concurrency cap and pool ceiling"). It mocks
* `readBestGranularityRollups` with a controllable promise so we can
* `readRollupBuckets` with a controllable promise so we can
* observe the in-flight count at the helper boundary and assert it
* never exceeds 4 across a 15-type fan-out.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: vi.fn(),
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/rollups/measurement-rollups")
>()),
readRollupBuckets: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-read", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rollups/measurement-read")>()),
loadUserSourcePriority: vi.fn(async () => null),
}));
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
WMY_FANOUT_CONCURRENCY,
computeAvg30LastYearMap,
} from "../summaries-slice";
const READ = readBestGranularityRollups as unknown as ReturnType<typeof vi.fn>;
const READ = readRollupBuckets as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
READ.mockReset();
@@ -45,7 +52,7 @@ describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => {
expect(WMY_FANOUT_CONCURRENCY).toBe(4);
});
it("holds at most 4 concurrent readBestGranularityRollups calls for a 15-type list", async () => {
it("holds at most 4 concurrent rollup reads for a 15-type list", async () => {
let inFlight = 0;
let peak = 0;
const resolvers: Array<() => void> = [];
@@ -58,7 +65,7 @@ describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => {
inFlight -= 1;
// Resolve with `null` — the helper treats no-coverage as
// `null` in the output map, which is fine for this test.
resolve(null);
resolve([]);
});
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { percentile } from "@/lib/insights/strain-score";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
import { localDayWindow } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
import {
BUCKET_MINUTES,
+1 -1
View File
@@ -22,7 +22,7 @@
*/
import { prisma } from "@/lib/db";
import { decodeKeyList } from "@/lib/mood/context-vocabulary";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { resolveLinkedDayFigures } from "@/lib/mood/linked-context";
import type { PrognosisDayInput } from "./features";
+2 -1
View File
@@ -22,7 +22,8 @@ import {
dayKeyAgeInDays,
isCurrentForTodayClaim,
} from "@/lib/insights/measurement-freshness";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import type { FeatureContribution } from "./forecast";
import { countQualifyingDays } from "./load";
+137
View File
@@ -25,14 +25,34 @@
* a window aggregate built from WEEK / MONTH buckets equals the per-row
* aggregate over the same span. SD / slope / r² are intentionally NOT part of
* the delta — they do not compose, matching the rest of the WMY reader tier.
*
* Two kinds of metric are not averaged per reading:
* - step-like totals (steps, energy, distance, flights, …) compare the
* average DAILY total: a day's readings are pieces of one total, and the
* last day and a half still arrives as many small samples before the
* drain folds them, so a per-reading mean collapsed the current window;
* - sleep compares the average NIGHT (time asleep, reconstructed from the
* stage rows): a per-row mean averaged stage segments and in-bed
* envelopes, which is no amount of sleep at all.
* For both, `count` is the number of days or nights.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
ANALYTICS_RANGES,
rangeWindowDays,
@@ -86,6 +106,99 @@ export function sliceWindowDelta(
};
}
/**
* Aggregate one value per day or night (a daily total, a night's time
* asleep): `count` is the number of days or nights, `mean` their average.
*/
export function aggregatePerPeriod(values: readonly number[]): WindowAggregate {
if (values.length === 0) {
return { count: 0, min: null, max: null, mean: null, sum: null };
}
let sum = 0;
let min = Infinity;
let max = -Infinity;
for (const v of values) {
sum += v;
if (v < min) min = v;
if (v > max) max = v;
}
return { count: values.length, min, max, mean: sum / values.length, sum };
}
/** Split `(at, value)` points into the current and previous halves. */
function splitPerPeriod(
points: ReadonlyArray<{ at: Date; value: number }>,
windowDays: number,
now: number,
): { current: WindowAggregate; previous: WindowAggregate } {
const currentStart = now - windowDays * DAY_MS;
const previousStart = now - 2 * windowDays * DAY_MS;
const current: number[] = [];
const previous: number[] = [];
for (const p of points) {
const t = p.at.getTime();
if (t >= currentStart && t < now) current.push(p.value);
else if (t >= previousStart && t < currentStart) previous.push(p.value);
}
return {
current: aggregatePerPeriod(current),
previous: aggregatePerPeriod(previous),
};
}
/** The canonical daily totals of a step-like metric, one point per day. */
async function readDailyTotals(
userId: string,
type: MeasurementType,
windowDays: number,
now: number,
): Promise<Array<{ at: Date; value: number }>> {
const rows = await readRollupBuckets(
userId,
type,
"DAY",
startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)),
new Date(now),
);
return rows.map((r) => ({ at: r.bucketStart, value: r.mean * r.count }));
}
/** Time asleep per reconstructed night, one point per night. */
async function readNightlySleep(
userId: string,
windowDays: number,
now: number,
): Promise<Array<{ at: Date; value: number }>> {
// A night's rows start the evening before its wake day: read one day more.
const rows = (await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: {
gte: new Date(now - (2 * windowDays + 1) * DAY_MS),
lt: new Date(now),
},
},
orderBy: { measuredAt: "asc" },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})) as SleepStageRow[];
if (rows.length === 0) return [];
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
return reconstructSleepNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ at: n.measuredAt, value: n.asleepMinutes }));
}
/**
* Compose the period-over-period delta from the two window aggregates.
* Pure — pinned by unit test. Guards both the missing-data case (either
@@ -119,6 +232,30 @@ export async function computeRangeDelta(
now: number = Date.now(),
): Promise<RangeDeltaResult> {
const windowDays = rangeWindowDays(range);
if (type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type)) {
const points =
type === "SLEEP_DURATION"
? await readNightlySleep(userId, windowDays, now)
: await readDailyTotals(userId, type, windowDays, now);
const { current, previous } = splitPerPeriod(points, windowDays, now);
const { delta, deltaPct } = composeDelta(current, previous);
return {
range,
windowDays,
granularity:
points.length === 0
? "none"
: type === "SLEEP_DURATION"
? "live"
: "DAY",
current,
previous,
delta,
deltaPct,
};
}
// Read the full 2N span in one go so the current and previous halves share
// a granularity; the router picks the coarsest tier that resolves 2N.
const resolved = await readBestGranularityRollups(
+7 -9
View File
@@ -39,7 +39,6 @@
* Pure & deterministic — unit-tested in `__tests__/resting-pulse.test.ts`.
*/
import { percentile } from "@/lib/insights/strain-score";
import { toBerlinDayKey } from "@/lib/tz/resolver";
/** A timestamped heart-rate sample. */
export interface PulseSample {
@@ -78,12 +77,11 @@ export const RESTING_PROXY_MIN_DAILY_SAMPLES = 3;
export function deriveRestingProxyFromPulse(
pulseSamples: ReadonlyArray<PulseSample>,
/**
* Day-key function used to bucket samples. Defaults to Berlin-day so
* direct callers / tests stay zero-config; the targets route passes
* its own `userDayKey(d, userTz)` so the proxy buckets align with the
* route's consistency-strip day buckets for non-Berlin users.
* Day-key function used to bucket samples: the user's own day
* (`userDayKey(d, userTz)`), so the proxy buckets line up with every other
* day bucket the user sees.
*/
dayKeyOf: (d: Date) => string = toBerlinDayKey,
dayKeyOf: (d: Date) => string,
): PulseSample[] {
if (pulseSamples.length === 0) return [];
const byDay = new Map<string, { firstAt: Date; values: number[] }>();
@@ -139,10 +137,10 @@ export function deriveRestingProxyFromPulse(
export function resolveRestingPulseSeries(input: {
restingSamples: ReadonlyArray<PulseSample>;
pulseSamples: ReadonlyArray<PulseSample>;
/** Optional day-key for the proxy buckets (defaults to Berlin-day). */
dayKeyOf?: (d: Date) => string;
/** Day key for the proxy buckets: the user's own day. */
dayKeyOf: (d: Date) => string;
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
const dayKeyOf = input.dayKeyOf ?? toBerlinDayKey;
const dayKeyOf = input.dayKeyOf;
const byTime = (a: PulseSample, b: PulseSample) =>
a.measuredAt.getTime() - b.measuredAt.getTime();
@@ -25,6 +25,16 @@ function days(count: number, value: number) {
}));
}
/** `count` completed days ending yesterday (activity leaves today out). */
function completedDays(count: number, value: number) {
return Array.from({ length: count }, (_, index) => ({
day: new Date(NOW.getTime() - (index + 1) * 86_400_000)
.toISOString()
.slice(0, 10),
value,
}));
}
describe("reference-score pillars", () => {
it("requires twelve fresh paired blood-pressure readings", () => {
const below = computeBloodPressurePillar({
@@ -174,7 +184,7 @@ describe("reference-score pillars", () => {
...live,
asOf: NOW,
ageYears: 40,
days: days(20, 10_000),
days: completedDays(20, 10_000),
sources: ["APPLE_HEALTH"],
}).status,
).toBe("insufficient");
@@ -183,14 +193,14 @@ describe("reference-score pillars", () => {
...live,
asOf: NOW,
ageYears: 40,
days: days(21, 12_000),
days: completedDays(21, 12_000),
sources: ["APPLE_HEALTH"],
});
const older = computeActivityPillar({
...live,
asOf: NOW,
ageYears: 68,
days: days(21, 8_000),
days: completedDays(21, 8_000),
sources: ["APPLE_HEALTH"],
});
expect(younger.status).toBe("ok");
@@ -209,7 +219,7 @@ describe("reference-score pillars", () => {
asOf: NOW,
timezone: "Europe/Berlin",
ageYears: 40,
days: days(21, 8_500),
days: completedDays(21, 8_500),
sources: ["APPLE_HEALTH"],
};
const liveResult = computeActivityPillar({ ...input, source: "live" });
@@ -224,7 +234,7 @@ describe("reference-score pillars", () => {
});
it("scores exactly 28 activity calendar keys", () => {
const points = days(29, 10_000);
const points = completedDays(29, 10_000);
points[28] = { ...points[28], value: 0 };
const result = computeActivityPillar({
...live,
@@ -237,6 +247,64 @@ describe("reference-score pillars", () => {
if (result.status === "ok") expect(result.value.score).toBe(100);
});
it("leaves today's partial step total out of the window", () => {
const withToday = [
{ day: "2026-07-28", value: 400 },
...completedDays(21, 10_000),
];
const result = computeActivityPillar({
...live,
asOf: NOW,
ageYears: 40,
days: withToday,
sources: ["APPLE_HEALTH"],
});
expect(result.status).toBe("ok");
if (result.status === "ok") expect(result.value.score).toBe(100);
// Twenty completed days plus today are not enough days.
expect(
computeActivityPillar({
...live,
asOf: NOW,
ageYears: 40,
days: [{ day: "2026-07-28", value: 400 }, ...completedDays(20, 10_000)],
sources: ["APPLE_HEALTH"],
}).status,
).toBe("insufficient");
});
it.each([
["America/New_York", "2026-03-09T03:30:00.000Z", "2026-03-08"],
["Pacific/Auckland", "2026-04-05T11:00:00.000Z", "2026-04-05"],
["Pacific/Tongatapu", "2026-07-28T11:30:00.000Z", "2026-07-29"],
["America/Santiago", "2026-09-06T04:30:00.000Z", "2026-09-06"],
["Europe/Berlin", "2026-10-25T23:30:00.000Z", "2026-10-26"],
])("cuts the window on local days in %s", (timezone, iso, today) => {
const asOf = new Date(iso);
const [y, m, d] = today.split("-").map(Number);
const key = (back: number) =>
new Date(Date.UTC(y, m - 1, d - back)).toISOString().slice(0, 10);
const window = Array.from({ length: 28 }, (_, i) => ({
day: key(i + 1),
value: 10_000,
}));
const result = computeActivityPillar({
source: "live",
readFailed: false,
timezone,
asOf,
ageYears: 40,
days: [{ day: today, value: 0 }, { day: key(29), value: 0 }, ...window],
sources: ["APPLE_HEALTH"],
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.score).toBe(100);
expect(result.coverage.historyDays).toBe(28);
}
});
it("requires fourteen sleep nights, never penalises long sleep, and adds regularity at 21 nights", () => {
const below = computeSleepPillar({
...live,
+20 -7
View File
@@ -1,5 +1,5 @@
import type { Derived } from "@/lib/insights/derived/types";
import { userDayKey } from "@/lib/tz/format";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import type { ActivityPillarInput, PillarValue } from "./types";
import {
@@ -18,6 +18,23 @@ export const OLDER_ADULT_AGE = 60;
export const YOUNGER_STEP_PLATEAU = 10_000;
export const OLDER_STEP_PLATEAU = 8_000;
/**
* The activity window: the `ACTIVITY_WINDOW_DAYS` completed local days that
* end yesterday, as inclusive `YYYY-MM-DD` keys. Today is left out because
* its step total is still growing: counting it pulled every score computed
* during the day down by a partial day.
*/
export function activityWindowKeys(
asOf: Date,
timezone: string,
): { startDay: string; endDay: string } {
const today = userDayKey(asOf, timezone);
return {
startDay: shiftDateKey(today, -ACTIVITY_WINDOW_DAYS),
endDay: shiftDateKey(today, -1),
};
}
export function computeActivityPillar(
input: ActivityPillarInput,
): Derived<PillarValue> {
@@ -29,13 +46,9 @@ export function computeActivityPillar(
});
}
const asOfDay = userDayKey(input.asOf, input.timezone);
const sinceDay = userDayKey(
new Date(input.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * 86_400_000),
input.timezone,
);
const { startDay, endDay } = activityWindowKeys(input.asOf, input.timezone);
const points = input.days.filter(
(point) => point.day <= asOfDay && point.day >= sinceDay,
(point) => point.day <= endDay && point.day >= startDay,
);
if (points.length < ACTIVITY_MIN_DAYS) {
return insufficientPillar({
+10 -10
View File
@@ -23,7 +23,7 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
import { annotate, getEvent } from "@/lib/logging/context";
import { ACTIVITY_WINDOW_DAYS } from "./activity";
import { ACTIVITY_WINDOW_DAYS, activityWindowKeys } from "./activity";
import { SLEEP_WINDOW_DAYS } from "./sleep";
import { attachScoreDelta } from "./composite";
import {
@@ -239,9 +239,8 @@ export async function readActivityDays(args: {
canonical
.filter((row) => {
const day = userDayKey(row.measuredAt, args.timezone);
const endDay = userDayKey(args.asOf, args.timezone);
const startDay = userDayKey(
new Date(args.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS),
const { startDay, endDay } = activityWindowKeys(
args.asOf,
args.timezone,
);
return day >= startDay && day <= endDay;
@@ -461,10 +460,7 @@ function scoreInputsFor(args: {
const { asOf, input } = args;
const ageYears = getAgeFromDateOfBirth(input.profile.dateOfBirth);
const asOfDay = userDayKey(asOf, input.profile.timezone);
const activitySinceDay = userDayKey(
new Date(asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS),
input.profile.timezone,
);
const activityWindow = activityWindowKeys(asOf, input.profile.timezone);
const sleepSinceDay = userDayKey(
new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS),
input.profile.timezone,
@@ -548,7 +544,11 @@ function scoreInputsFor(args: {
days: args.activity.value.days,
sources: uniqueSources(
args.activity.value.sourceDays
.filter((row) => row.day >= activitySinceDay && row.day <= asOfDay)
.filter(
(row) =>
row.day >= activityWindow.startDay &&
row.day <= activityWindow.endDay,
)
.map((row) => row.source),
),
},
@@ -629,7 +629,7 @@ export async function computeUserHealthScore(
const db = input.prisma ?? prisma;
const since = new Date(input.now.getTime() - SCORE_READ_DAYS * DAY_MS);
const activitySince = new Date(
input.now.getTime() - (28 + HISTORY_OFFSET_DAYS) * DAY_MS,
input.now.getTime() - (ACTIVITY_WINDOW_DAYS + HISTORY_OFFSET_DAYS) * DAY_MS,
);
const [
glucose,
+1 -1
View File
@@ -147,7 +147,7 @@ export function canonicalMeasurementsCte(
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
) c
ON c.t = mm."type"
AND c.d = date_trunc('day', mm."measured_at")
+91 -134
View File
@@ -57,6 +57,7 @@
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -64,9 +65,11 @@ import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import {
ensureUserRollupsFresh,
readRollupBuckets,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
import {
aggregateBuckets,
collapseRollupRowsBySource,
composeWindowedRegression,
hasPendingAccumulatorBackfill,
@@ -83,7 +86,6 @@ import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
summarizeSleepNights,
type SleepSourceDiscrepancy,
@@ -423,6 +425,11 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
'"source"',
);
const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
const rankPerSource = buildSourceRankCase(
priorityJson,
'p."type"',
'p."source"',
);
// v1.37.19 (A6-10) — where the fold window starts. DAY rollup buckets
// only exist inside the trailing `ROLLUP_FOLD_WINDOW_MS` (5 y), so the
@@ -514,7 +521,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
WHERE "user_id" = $1
AND "granularity" = 'DAY'
AND "bucket_start" >= $2
ORDER BY "type", "bucket_start", (${rankUnqualified}), "source"
ORDER BY "type", "bucket_start", (${rankUnqualified}), "source"::text
)
SELECT
"type"::text AS type,
@@ -559,16 +566,15 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
sumYy: true,
},
}),
// v1.37.19 (A6-10) — all-time remainder OLDER than the fold window.
// One grouped aggregate over the pre-fold rows (index range scan on
// `(user_id, measured_at)`; empty for any account younger than the
// window). Spliced into the per-type figures below so "all-time"
// means all time on the rollup path too. Deliberately without the
// source-priority collapse: pre-fold history predates the rollup
// tier's per-day canonicalisation, and a per-day DISTINCT ON over a
// multi-year span is the cost this splice exists to avoid — for
// count/min/max/mean over years-old data the raw figures are the
// honest ones.
// v1.37.19 (A6-10) — all-time remainder OLDER than the fold window,
// spliced into the per-type figures below so "all-time" means all time
// on the rollup path too (empty for any account younger than the
// window). It keeps one source per (type, day) the way the rollup half
// and the live path do: without it, a vital recorded by two devices
// counted both of them on every pre-fold day. Each source is folded per
// day first (a hash aggregate, a few rows per day however dense the
// stream), each day then keeps its ladder-canonical source, so the
// sort never sees more than one row per source per day.
prisma.$queryRaw<
Array<{
type: string;
@@ -578,17 +584,35 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
mean: number;
}>
>`
WITH per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" < ${foldSplit}
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value
FROM per_source p
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
)
SELECT
m."type"::text AS type,
COUNT(*)::int AS count,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
AVG(m."value")::double precision AS mean
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" < ${foldSplit}
GROUP BY m."type"
c."type"::text AS type,
SUM(c.cnt)::int AS count,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max,
(SUM(c.total) / SUM(c.cnt))::double precision AS mean
FROM canon c
GROUP BY c."type"
`,
]);
@@ -773,11 +797,10 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
};
}
// v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the
// WMY rollup tier. Only types with data in the current window are
// probed so we don't pay the per-type round-trip on the long tail
// of unlogged measurement enums. Types without YEAR/MONTH coverage
// surface as `null` (existing behaviour) — additive only.
// Populate `avg30LastYear` per type from the DAY rollup tier. Only types
// with data in the current window are probed so we don't pay the per-type
// round-trip on the long tail of unlogged measurement enums. Types with no
// reading in the year-ago window surface as `null`.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
@@ -1016,13 +1039,10 @@ async function computeFromLiveAggregate(
};
}
// v1.4.40 W-WMY-WIRE — even on the live-aggregate fallback (DAY
// coverage incomplete), the YEAR / MONTH rollup tier may still
// carry the year-ago baseline because the boot-time backfill mints
// all granularities once per user. Probing here means a partial-
// coverage cold mount still surfaces `avg30LastYear` whenever the
// long-tail tier is populated, instead of waiting for the next
// refresh cycle.
// Even on the live-aggregate fallback (trailing DAY coverage incomplete),
// the year-ago DAY buckets may already exist because the boot-time
// backfill folds the whole history once per user, so a partial-coverage
// cold mount still surfaces `avg30LastYear`.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
@@ -1051,118 +1071,51 @@ async function computeFromLiveAggregate(
}
/**
* v1.4.39 W-WMY — long-window summary for a single `(userId, type)`
* pair, served from the WEEK / MONTH / YEAR rollup tier when the
* window justifies it.
* Year-ago 30-day baseline per type, served from the DAY rollup tier.
*
* Why this helper exists
* ----------------------
* The slim `computeSummariesSlice` caps its windowed columns at 90 d
* (`slope7 / slope30`). The v1.5 multi-year trend feature
* + the Coach drawer's "history" tile need linearly composable stats
* (count / min / max / mean / sum) over much larger windows — 1 y,
* 2 y, 3 y. Hitting the live `measurements` table for a 3-year span
* walks every row the user has ever logged for that type, which on
* the maintainer's tenant is tens of thousands of rows per type.
* `DataSummary.avg30LastYear` is the mean over the 30 days that start 395
* days ago, which the dashboard tile uses for "current average vs the same
* time last year". The window is exactly those 30 UTC days (the DAY-tier
* grain), read as canonical-source DAY buckets and composed count-weighted,
* so it is the same per-reading mean `avg30` reports for the last 30 days.
*
* The WMY rollup tier already carries the per-bucket stats we need:
* the writer mints WEEK / MONTH / YEAR buckets via pg-boss on every
* measurement write and the boot backfill fills the long tail. This
* helper routes the requested window into the largest granularity
* that still resolves it (via `readBestGranularityRollups`) and
* composes the trailing-window aggregate in JS — typically 3-15
* bucket rows on a multi-year window, vs the thousands of raw rows
* the live aggregator would scan.
* Up to v1.39.2 this read the MONTH tier and kept the months whose first day
* fell inside the window, so the baseline was a whole calendar month that
* could start up to a month off, or nothing at all when no month began inside
* the window.
*
* Compositional caveats
* ---------------------
* `count / min / max / mean / sum` are mathematically exact across
* any granularity (linearly composable). `sd / slope / r2` are NOT
* exact across coarser buckets — this helper deliberately omits them
* so callers cannot accidentally consume stale slope data. The v1.5
* multi-year trend card derives its slope from the per-bucket `mean`
* series instead.
*
* Coverage-miss policy
* --------------------
* `readBestGranularityRollups` falls back from YEAR → MONTH → WEEK
* → DAY on per-tier coverage miss. Returns `null` only when the
* user has zero buckets in the entire window — the caller treats
* that as "no data" rather than "stale rollup". The route is
* intentionally NOT wired to live SQL fallback here; the all-time
* aggregate path inside `computeSummariesSlice` already covers the
* "rollup tier empty" cold-mount case, and v1.5's multi-year card
* accepts a `null` "no data yet" state.
*/
/**
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
* the WEEK / MONTH / YEAR rollup tier.
*
* Why this helper exists
* ----------------------
* `DataSummary.avg30LastYear` is the mean over `[now-395d, now-365d)` —
* the 30-day window starting 365 days ago. The dashboard tile-strip's
* delta callout uses it to narrate "current avg vs same time last
* year". Up to v1.4.39 the slim slice (and the comprehensive
* aggregator's rollup branch) hardcoded the field to `null` because
* the 90-day windowed `$queryRaw` cannot reach back a year, and the
* live `measurements` table walk for a 395-day per-type window is
* exactly the cold-path cost we're trying to avoid.
*
* The WMY rollup tier already carries the per-bucket stats this
* window needs. `readBestGranularityRollups(userId, type, 395)`
* routes the 395-day window through YEAR (if covered) → MONTH → WEEK
* → DAY and returns the buckets whose `bucketStart` falls inside the
* window. We then keep only the buckets that overlap the
* `[now-395d, now-365d)` slice and compose `count / mean` linearly to
* produce the year-ago baseline.
*
* Compositional contract
* ----------------------
* `count / mean` are linearly composable across any granularity, so
* the year-ago mean derived from MONTH or YEAR buckets is
* mathematically equivalent to the per-row average over the same
* window. The bucket-overlap filter is conservative: we include only
* buckets whose `bucketStart` is strictly inside the slice. On a
* YEAR-granularity routing this collapses to at most one bucket and
* approximates the 30-day mean by the surrounding yearly average —
* acceptable for a UI hint that explicitly narrates "last year".
*
* Coverage-miss policy
* --------------------
* Returns `null` when `readBestGranularityRollups` returns null (no
* coverage at any granularity) OR when no buckets overlap the
* year-ago slice. The caller leaves the dashboard field as `null`,
* which the UI already handles as "no comparison available".
* Returns `null` when no reading falls in the window; the UI treats that as
* "no comparison available".
*/
async function computeAvg30LastYearForType(
userId: string,
type: MeasurementType,
priorityJson: unknown,
now: number,
): Promise<number | null> {
const resolved = await readBestGranularityRollups(userId, type, 395);
if (!resolved) return null;
const now = Date.now();
const sliceStart = now - 395 * DAY_MS;
const sliceEnd = now - 365 * DAY_MS;
const overlapping = resolved.rows.filter((row) => {
const t = row.bucketStart.getTime();
return t >= sliceStart && t < sliceEnd;
});
if (overlapping.length === 0) return null;
let totalCount = 0;
let weighted = 0;
for (const row of overlapping) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount === 0) return null;
return weighted / totalCount;
const rows = await readRollupBuckets(
userId,
type,
"DAY",
startOfUtcDay(new Date(now - 395 * DAY_MS)),
startOfUtcDay(new Date(now - 365 * DAY_MS)),
priorityJson,
);
const composed = aggregateBuckets(
rows.map((r) => ({
day: r.bucketStart,
count: r.count,
mean: r.mean,
minValue: r.minValue,
maxValue: r.maxValue,
})),
);
return composed.count > 0 ? composed.mean : null;
}
/**
* v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across
* the types the caller actually surfaces. Runs the per-type WMY reads
* the types the caller actually surfaces. Runs the per-type rollup reads
* in parallel so the long-tail of types doesn't serialise into a
* per-type round-trip stack.
*
@@ -1191,12 +1144,16 @@ export async function computeAvg30LastYearMap(
const out = new Map<string, number | null>();
if (types.length === 0) return out;
const limit = pLimit(WMY_FANOUT_CONCURRENCY);
const priorityJson = await loadUserSourcePriority(userId);
const now = Date.now();
const results = await Promise.all(
types.map((type) =>
limit(async () => {
const value = await computeAvg30LastYearForType(
userId,
type as MeasurementType,
priorityJson,
now,
);
return [type, value] as const;
}),
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { heatmapDays } from "../heatmap-days";
describe("heatmapDays", () => {
it.each([
// 20:30 in New York is already the next UTC day.
["America/New_York", "2026-03-09T00:30:00.000Z", "2026-03-08"],
// 08:00 in Auckland and Tonga is still the previous UTC day.
["Pacific/Auckland", "2026-04-04T20:00:00.000Z", "2026-04-05"],
["Pacific/Tongatapu", "2026-07-27T19:00:00.000Z", "2026-07-28"],
["America/Santiago", "2026-09-06T03:30:00.000Z", "2026-09-05"],
["Europe/Berlin", "2026-10-25T22:30:00.000Z", "2026-10-25"],
])("ends on today's key in %s", (tz, iso, today) => {
const days = heatmapDays(new Date(iso), tz, 14);
expect(days).toHaveLength(14);
expect(days.at(-1)?.dateKey).toBe(today);
// Consecutive keys, one per day, no gap or repeat across DST.
for (let i = 1; i < days.length; i++) {
const prev = Date.parse(`${days[i - 1].dateKey}T00:00:00Z`);
const cur = Date.parse(`${days[i].dateKey}T00:00:00Z`);
expect(cur - prev).toBe(86_400_000);
}
});
it("reads weekday and month off the key", () => {
const [last] = heatmapDays(
new Date("2026-03-01T03:00:00.000Z"),
"America/New_York",
1,
);
// 22:00 on Saturday 28 February in New York.
expect(last).toEqual({ dateKey: "2026-02-28", dow: 5, month: 1 });
});
});
+36
View File
@@ -0,0 +1,36 @@
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
/** One calendar-heatmap column entry: a local day and where it sits. */
export interface HeatmapDay {
/** `YYYY-MM-DD` in the user's zone. */
dateKey: string;
/** Weekday with Monday = 0 … Sunday = 6. */
dow: number;
/** Month index 0–11. */
month: number;
}
/**
* The last `days` local days in `timeZone`, oldest first, ending with today.
*
* Built from today's key in the user's zone and stepped on keys. The heatmaps
* used to take `now − n·24 h` and cut the key in UTC, so in the evening west
* of UTC (or the morning east of it) "today" was a different day from the one
* the server had keyed the user's readings on, and the newest cell showed the
* wrong day. Weekday and month come from the key itself.
*/
export function heatmapDays(
now: Date,
timeZone: string,
days: number,
): HeatmapDay[] {
const today = userDayKey(now, timeZone);
const out: HeatmapDay[] = [];
for (let back = days - 1; back >= 0; back--) {
const dateKey = shiftDateKey(today, -back);
const [y, m, d] = dateKey.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, d));
out.push({ dateKey, dow: (utc.getUTCDay() + 6) % 7, month: m - 1 });
}
return out;
}
+33 -6
View File
@@ -119,7 +119,11 @@ describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", ()
it("surfaces a luteal-vs-follicular GLUCOSE contrast", () => {
const { phaseByDay, measurements } = contrastFor("BLOOD_GLUCOSE", 105, 92);
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const rows = computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
});
const glucose = rows.find((r) => r.metricKey === "bloodGlucose");
expect(glucose).toBeDefined();
expect(glucose!.display).toBe("glucose");
@@ -133,7 +137,11 @@ describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", ()
4.4,
2.6,
);
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const rows = computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
});
const mood = rows.find((r) => r.metricKey === "mood");
expect(mood).toBeDefined();
expect(mood!.display).toBe("mood");
@@ -164,7 +172,11 @@ describe("computePhaseMetricCrosstab", () => {
lutealRhr: 63,
follicularRhr: 57,
});
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const rows = computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
});
const rhr = rows.find((r) => r.metricKey === "restingHeartRate");
expect(rhr).toBeDefined();
// luteal higher → positive delta (lutealAvg − follicularAvg)
@@ -183,7 +195,11 @@ describe("computePhaseMetricCrosstab", () => {
lutealRhr: 63,
follicularRhr: 57,
});
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const rows = computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
});
expect(rows).toEqual([]);
});
@@ -193,7 +209,11 @@ describe("computePhaseMetricCrosstab", () => {
lutealRhr: 60,
follicularRhr: 60,
});
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const rows = computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
});
// No real separation → nothing clears p < 0.05.
expect(
rows.find((r) => r.metricKey === "restingHeartRate"),
@@ -216,7 +236,11 @@ describe("computePhaseMetricCrosstab", () => {
phaseByDay.set(key, i % 2 === 0 ? "MENSTRUAL" : "OVULATORY");
measurements.push(m("RESTING_HEART_RATE", key, 200));
}
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const rows = computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
});
const rhr = rows.find((r) => r.metricKey === "restingHeartRate")!;
// Means reflect only the luteal/follicular days, not the 200-bpm noise.
expect(rhr.lutealAvg).toBeLessThan(70);
@@ -226,6 +250,7 @@ describe("computePhaseMetricCrosstab", () => {
it("returns empty for an empty phase map", () => {
expect(
computePhaseMetricCrosstab({
timeZone: "Europe/Berlin",
phaseByDay: new Map(),
measurements: [m("RESTING_HEART_RATE", "2026-03-01", 60)],
}),
@@ -287,6 +312,7 @@ describe("discoverPhaseCorrelations", () => {
);
}
const result = discoverPhaseCorrelations({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
locale: "en",
@@ -320,6 +346,7 @@ describe("discoverPhaseCorrelations", () => {
measurements.push(m("RESTING_HEART_RATE", key, 60)); // constant → no variance
}
const result = discoverPhaseCorrelations({
timeZone: "Europe/Berlin",
phaseByDay,
measurements,
locale: "en",
+16 -2
View File
@@ -183,6 +183,8 @@ export function computePhaseMetricCrosstab(args: {
measurements: CrossMetricMeasurement[];
/** The user's source-priority blob (threaded into `metricDayMap`). */
userPriorityJson?: unknown;
/** The user's zone, the calendar the phase days are keyed on. */
timeZone: string;
}): PhaseMetricCrosstabRow[] {
const { phaseByDay, measurements } = args;
const userPriorityJson = args.userPriorityJson ?? null;
@@ -198,7 +200,12 @@ export function computePhaseMetricCrosstab(args: {
(typeof PHASE_CROSSTAB_METRICS)[PhaseCrosstabMetricKey],
]
>) {
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
const metricByDay = metricDayMap(
measurements,
cfg.type,
userPriorityJson,
args.timeZone,
);
if (metricByDay.size === 0) continue;
const lutealVals: number[] = [];
@@ -293,6 +300,8 @@ export function discoverPhaseCorrelations(args: {
phaseByDay: Map<string, CyclePhase>;
measurements: CrossMetricMeasurement[];
userPriorityJson?: unknown;
/** The user's zone, the calendar the phase days are keyed on. */
timeZone: string;
/**
* The reader's locale. Each surviving row carries a finished `interpretation`
* sentence that the cycle route serves straight to the client, so it has to
@@ -307,7 +316,12 @@ export function discoverPhaseCorrelations(args: {
const phaseSeries = buildPhaseDiscoverySeries(phaseByDay);
const outcomeSeries: NamedSeries[] = [];
for (const [, cfg] of Object.entries(PHASE_CROSSTAB_METRICS)) {
const byDay = metricDayMap(measurements, cfg.type, userPriorityJson);
const byDay = metricDayMap(
measurements,
cfg.type,
userPriorityJson,
args.timeZone,
);
if (byDay.size === 0) continue;
const points: DailySeriesPoint[] = [];
for (const [day, value] of byDay) points.push({ day, value });
+2 -1
View File
@@ -25,7 +25,8 @@ import {
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
import { addDays } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** Day-log lookback: the calendar's default past span or the symptothermal
* window, whichever reaches further back. */
@@ -63,6 +63,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { resolveModuleMap } from "@/lib/modules/gate";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
@@ -118,6 +119,36 @@ beforeEach(() => {
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
});
describe("loadDailyDigest — milestone day", () => {
// 21:30 on 16 July in New York is already 17 July in UTC. A best set at
// 19:00 local that evening was reached today, on the user's calendar.
const NY_USER = { ...USER, timezone: "America/New_York" } as User;
const NY_EVENING = new Date("2026-07-17T01:30:00.000Z");
it("keys today and a new record on the user's own day", async () => {
vi.mocked(prisma.personalRecord.findMany).mockResolvedValueOnce([
{
metricType: "RESTING_HEART_RATE",
achievedAt: new Date("2026-07-16T23:00:00.000Z"),
},
] as never);
const digest = await loadDailyDigest(NY_USER, NY_EVENING);
expect(digest.worthALook.some((i) => i.kind === "milestone")).toBe(true);
});
it("folds the streak series on the user's days", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(
new Map([["RESTING_HEART_RATE", true]]) as never,
);
await loadDailyDigest(NY_USER, NY_EVENING);
const calls = vi.mocked(readDayMeanSeries).mock.calls;
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) expect(call[5]).toBe("America/New_York");
});
});
describe("loadDailyDigest — S11/S12 extras cache", () => {
it("gathers the milestone + tension inputs once, then serves the second poll from cache", async () => {
await loadDailyDigest(USER, NOW);
+2 -2
View File
@@ -33,7 +33,7 @@ import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import type { DailyDigest } from "@/lib/daily/digest";
@@ -175,7 +175,7 @@ export async function maybeDispatchDailyBriefing(
return "opted-out";
}
const tz = user.timezone || "Europe/Berlin";
const tz = user.timezone || DEFAULT_TIMEZONE;
const { hour } = wallClockInTz(now, tz);
if (
hour < DAILY_BRIEFING_MORNING_EARLIEST_HOUR ||
+11 -8
View File
@@ -101,11 +101,6 @@ const MILESTONE_STREAK_WINDOW_DAYS = 30;
/** How far back to scan for a just-set personal best (a couple of days is ample). */
const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000;
/** UTC-ISO day key — the space the streak series + rollup tier already emit. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* S12 — gather today's single freshly-reached milestone (or null) from the
* engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim:
@@ -117,9 +112,13 @@ function utcDayKey(date: Date): string {
*/
async function gatherFreshMilestone(
userId: string,
timezone: string,
now: Date,
): Promise<Milestone | null> {
const todayKey = utcDayKey(now);
// Every day key here is the user's own calendar day: today's key, the
// streak series (folded on local days) and each record's day. UTC keys
// named tomorrow "today" through a New York evening.
const todayKey = userDayKey(now, timezone);
const candidates: Milestone[] = [];
const coverage = await probeRollupCoverage(userId).catch(() => null);
@@ -133,6 +132,7 @@ async function gatherFreshMilestone(
MILESTONE_STREAK_WINDOW_DAYS,
now,
coverage,
timezone,
);
if (points.length === 0) return [] as Milestone[];
const series: StreakPoint[] = points.map((p) => ({
@@ -164,7 +164,10 @@ async function gatherFreshMilestone(
});
for (const record of records) {
candidates.push(
milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)),
milestoneFromRecord(
record.metricType,
userDayKey(record.achievedAt, timezone),
),
);
}
} catch {
@@ -234,7 +237,7 @@ async function loadDailyDigestExtrasCached(
// internally (a milestone read-hiccup or a tension-read failure leaves
// its own field quiet rather than breaking the other).
const [milestone, tensionWindow, sameTime] = await Promise.all([
gatherFreshMilestone(userId, now),
gatherFreshMilestone(userId, timezone, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null),
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { computeStreak } from "../streak";
import { userDayKey } from "@/lib/tz/format";
const ZONES = [
"America/New_York",
"America/Santiago",
"Pacific/Auckland",
"Pacific/Tongatapu",
"Europe/Berlin",
"UTC",
];
/** The last `n` local days ending with today's key in `tz`. */
function lastDays(now: Date, tz: string, n: number, skipToday = false) {
const keys = new Set<string>();
const today = userDayKey(now, tz);
const [y, m, d] = today.split("-").map(Number);
for (let i = skipToday ? 1 : 0; i < n + (skipToday ? 1 : 0); i++) {
keys.add(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10));
}
return keys;
}
describe("computeStreak", () => {
// Evening in New York is already the next UTC day, and the early hours in
// Auckland or Tonga are still the previous UTC day: both ends of the walk.
const INSTANTS = [
"2026-03-08T01:30:00.000Z",
"2026-03-08T23:30:00.000Z",
"2026-04-05T13:00:00.000Z",
"2026-09-06T03:30:00.000Z",
"2026-10-25T00:30:00.000Z",
"2026-11-01T06:00:00.000Z",
];
for (const tz of ZONES) {
for (const iso of INSTANTS) {
const now = new Date(iso);
it(`counts three logged days ending today in ${tz} at ${iso}`, () => {
const streak = computeStreak(lastDays(now, tz, 3), tz, now);
expect(streak.currentDays).toBe(3);
expect(streak.longest).toBe(3);
});
it(`counts from yesterday when today is not logged yet in ${tz} at ${iso}`, () => {
const streak = computeStreak(lastDays(now, tz, 4, true), tz, now);
expect(streak.currentDays).toBe(4);
});
}
}
it("breaks the current streak on a gap", () => {
const now = new Date("2026-06-10T20:00:00.000Z");
const tz = "America/New_York";
const streak = computeStreak(
new Set([
"2026-06-10",
"2026-06-09",
"2026-06-07",
"2026-06-06",
"2026-06-05",
]),
tz,
now,
);
expect(streak.currentDays).toBe(2);
expect(streak.longest).toBe(3);
});
it("returns zeros for no activity", () => {
expect(computeStreak(new Set(), "UTC")).toEqual({
currentDays: 0,
longest: 0,
});
});
});
+8 -20
View File
@@ -25,16 +25,12 @@
* prefetch-off / legacy / fail-soft paths where no prop arrives.
*
* Import-safe from the server RSC and the client component alike: pure, no
* Prisma / db / `node:*` imports (only the client-safe `@/lib/tz/format`
* Intl helpers and the layout type).
* Prisma / db / `node:*` imports (only the client-safe `@/lib/tz` Intl
* helpers and the layout type).
*/
import type { DashboardLayout } from "@/lib/dashboard-layout";
import {
DEFAULT_TIMEZONE,
isValidTimezone,
tzOffsetMinutes,
userDayKey,
} from "@/lib/tz/format";
import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format";
import { localDayWindow } from "@/lib/tz/local-day";
/**
* Day-span the batched dashboard series (`series-batch`) fetches. Threaded to
@@ -97,21 +93,13 @@ export interface BatchWindow {
}
/**
* End-of-day of `now` in `timezone`, as a UTC instant. Falls back to the
* project default zone for an unusable IANA id. Two-step offset refine so the
* rare DST edge (a transition between the as-if-UTC guess and the true local
* instant) still resolves to the right offset.
* Last millisecond of the local day `now` falls on in `timezone`, as a UTC
* instant. Falls back to the project default zone for an unusable IANA id.
*/
function endOfLocalDayUtc(now: Date, timezone: string): Date {
const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE;
const dayKey = userDayKey(now, safeTz); // YYYY-MM-DD in the zone
const [year, month, day] = dayKey.split("-").map(Number);
// Wall-clock 23:59:59.999 treated as-if-UTC, then shifted by the zone offset.
const asIfUtc = Date.UTC(year, month - 1, day, 23, 59, 59, 999);
const offset1 = tzOffsetMinutes(new Date(asIfUtc), safeTz);
const corrected = new Date(asIfUtc - offset1 * 60_000);
const offset2 = tzOffsetMinutes(corrected, safeTz);
return new Date(asIfUtc - offset2 * 60_000);
const { dayEnd } = localDayWindow(userDayKey(now, safeTz), safeTz);
return new Date(dayEnd.getTime() - 1);
}
/**
+47
View File
@@ -0,0 +1,47 @@
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
export interface StreakInfo {
currentDays: number;
longest: number;
}
/**
* The current logging-day streak (days where any measurement or intake
* event was recorded, in the user's zone) plus the longest streak among
* `activityDays`.
*
* `activityDays` holds `YYYY-MM-DD` keys already cut in `userTz`. The walk
* steps through day KEYS (`shiftDateKey`), never instants: stepping back from
* UTC midnight of today's key read that instant in the user's zone, which
* west of UTC is still yesterday, so the streak started one day early and
* came out one day short. Yesterday counts as the streak's head when today
* has not been logged yet.
*/
export function computeStreak(
activityDays: ReadonlySet<string>,
userTz: string,
now: Date = new Date(),
): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
let longest = 1;
let run = 1;
for (let i = 1; i < sorted.length; i++) {
if (shiftDateKey(sorted[i - 1], 1) === sorted[i]) {
run += 1;
longest = Math.max(longest, run);
} else {
run = 1;
}
}
let cursor = userDayKey(now, userTz);
if (!activityDays.has(cursor)) cursor = shiftDateKey(cursor, -1);
let currentDays = 0;
while (activityDays.has(cursor)) {
currentDays += 1;
cursor = shiftDateKey(cursor, -1);
}
return { currentDays, longest };
}
+2 -2
View File
@@ -22,7 +22,6 @@ import {
import { getUnitForType } from "./validations/measurement";
import {
makeFormatters,
DISPLAY_TIMEZONE,
type DateFormatPreference,
type TimeFormatPreference,
} from "./format-locale";
@@ -39,6 +38,7 @@ import {
pdfCursorState,
type DoctorReportPdfRenderContext,
} from "./doctor-report-pdf/render-context";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type T = (key: string, params?: Record<string, string | number>) => string;
@@ -236,7 +236,7 @@ export function buildDoctorReportPdfDocument(
formatters.number(value, decimals);
const fmtDate = (iso: string) => formatters.date(iso);
const footerTz =
userTz && isValidTimezone(userTz) ? userTz : DISPLAY_TIMEZONE;
userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE;
const unitFor = (type: string): string => {
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
+35 -5
View File
@@ -61,7 +61,7 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import { validTimezoneOr } from "@/lib/tz/format";
import { validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
loadAllergies,
loadAnamnesis,
@@ -147,7 +147,7 @@ export async function collectDoctorReportData(
insurerIkNumber: true,
},
});
const reportTz = validTimezoneOr(userProfile?.timezone, "Europe/Berlin");
const reportTz = validTimezoneOr(userProfile?.timezone, DEFAULT_TIMEZONE);
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
@@ -189,7 +189,34 @@ export async function collectDoctorReportData(
}),
wantsMedications
? prisma.medication.findMany({
where: { userId, active: true },
// The report describes its window, and a link can pin that window
// in the past: a medication whose course had not begun by the
// window's end, or had ended before it began, is not part of it.
// One with a dose scheduled inside the window was taken then,
// whenever it was entered. Dose history and the last intake stop
// at the window's end too, so a report for March does not show a
// dose set in June.
where: {
userId,
active: true,
AND: [
{
OR: [
{ startsOn: { lte: end } },
{ startsOn: null, createdAt: { lte: end } },
{
intakeEvents: {
some: {
deletedAt: null,
scheduledFor: { gte: start, lte: end },
},
},
},
],
},
{ OR: [{ endsOn: null }, { endsOn: { gte: start } }] },
],
},
include: {
schedules: {
select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true },
@@ -205,9 +232,12 @@ export async function collectDoctorReportData(
},
},
pauseEras: { select: { pausedAt: true, resumedAt: true } },
doseChanges: { orderBy: { effectiveFrom: "asc" } },
doseChanges: {
where: { effectiveFrom: { lte: end } },
orderBy: { effectiveFrom: "asc" },
},
intakeEvents: {
where: { takenAt: { not: null } },
where: { takenAt: { not: null, lte: end } },
orderBy: { takenAt: "desc" },
take: 1,
select: { takenAt: true, injectionSite: true },
+1 -2
View File
@@ -27,8 +27,7 @@ import {
reanchorAppointmentReminder,
type AppointmentReminderInput,
} from "@/lib/encounters/appointment-reminder";
const DEFAULT_TIMEZONE = "Europe/Berlin";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* The record owner's zone AND language, read together.
+50
View File
@@ -398,6 +398,56 @@ describe("VO2 max", () => {
});
});
describe("mappers in the user's zone", () => {
it("reads a weigh-in's logged wall clock in the user's zone", () => {
const entry = {
weight: [
{ date: "2026-05-10", time: "07:30:00", weight: 81.4, logId: 7 },
],
};
expect(
mapWeight(entry, "Pacific/Auckland")[0]!.measuredAt.toISOString(),
).toBe("2026-05-09T19:30:00.000Z");
expect(
mapWeight(entry, "America/New_York")[0]!.measuredAt.toISOString(),
).toBe("2026-05-10T11:30:00.000Z");
// The externalId does not depend on the zone.
expect(mapWeight(entry, "Pacific/Auckland")[0]!.fieldTag).toBe("7:weight");
});
it("keeps a logId-less weigh-in's externalId as earlier versions spelled it", () => {
const out = mapWeight(
{ weight: [{ date: "2026-05-10", time: "07:30:00", weight: 81.4 }] },
"Pacific/Auckland",
);
expect(out[0]!.fieldTag).toBe("2026-05-10T07:30:00.000Z:weight");
});
it.each([
["Pacific/Tongatapu", "2026-05-09T23:00:00.000Z"],
["Pacific/Auckland", "2026-05-10T00:00:00.000Z"],
["America/Santiago", "2026-05-10T16:00:00.000Z"],
["America/New_York", "2026-05-10T16:00:00.000Z"],
["Europe/Berlin", "2026-05-10T10:00:00.000Z"],
])(
"anchors a daily total at local noon in %s, on the same day there",
(tz, iso) => {
const [steps] = mapSteps(
{ "activities-steps": [{ dateTime: "2026-05-10", value: "8421" }] },
tz,
);
expect(steps!.measuredAt.toISOString()).toBe(iso);
expect(steps!.fieldTag).toBe("steps:2026-05-10");
const [spo2] = mapOxygenSaturation(
[{ dateTime: "2026-05-10", value: { avg: 97 } }],
tz,
);
expect(spo2!.measuredAt.toISOString()).toBe(iso);
expect(spo2!.fieldTag).toBe("2026-05-10:spo2");
},
);
});
describe("activity mappers (cumulative daily)", () => {
it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => {
const out = mapSteps({
+59 -8
View File
@@ -288,7 +288,12 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
// A re-fetched daily summary: the live row already exists.
prismaMock.measurement.findMany.mockResolvedValue([
{ id: "m-live", type: "WEIGHT", externalId: "stats:weight:2026-05-10" },
{
id: "m-live",
type: "WEIGHT",
externalId: "stats:weight:2026-05-10",
measuredAt,
},
]);
const { imported } = await upsertFitbitMeasurements("user1", [
@@ -319,7 +324,12 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
// probe returns it; planning an insert instead would be dropped silently
// by `skipDuplicates` and wedge the key forever.
prismaMock.measurement.findMany.mockResolvedValue([
{ id: "m-dead", type: "WEIGHT", externalId: "stats:weight:2026-05-10" },
{
id: "m-dead",
type: "WEIGHT",
externalId: "stats:weight:2026-05-10",
measuredAt,
},
]);
const { imported } = await upsertFitbitMeasurements("user1", [
@@ -342,11 +352,52 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
expect(updateArg.data.deletedAt).toBeNull();
});
it("moves a daily row to its new anchor and refolds the day it left", async () => {
// A step total written at the old noon-UTC anchor, re-fetched for a user
// in Tonga, whose local noon is 23:00 UTC the day before.
const legacy = new Date("2026-05-10T12:00:00.000Z");
const localNoon = new Date("2026-05-09T23:00:00.000Z");
prismaMock.measurement.findMany.mockResolvedValue([
{
id: "m-steps",
type: "ACTIVITY_STEPS",
externalId: "stats:steps:2026-05-10",
value: 8421,
unit: "steps",
measuredAt: legacy,
sleepStage: null,
deletedAt: null,
},
]);
const { touched } = await upsertFitbitMeasurements("user1", [
{
type: "ACTIVITY_STEPS",
value: 8421,
unit: "steps",
measuredAt: localNoon,
externalId: "stats:steps:2026-05-10",
},
]);
expect(prismaMock.measurement.createManyAndReturn).not.toHaveBeenCalled();
expect(prismaMock.measurement.update).toHaveBeenCalledTimes(1);
const updateArg = prismaMock.measurement.update.mock.calls[0]![0] as {
data: { measuredAt: Date };
};
expect(updateArg.data.measuredAt).toEqual(localNoon);
expect(touched.map((t) => t.measuredAt.toISOString())).toEqual(
expect.arrayContaining([localNoon.toISOString(), legacy.toISOString()]),
);
});
it("splits a mixed batch: matched-live → update, unmatched → createMany", async () => {
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
prismaMock.measurement.findMany.mockResolvedValue([
{ id: "m-live", type: "WEIGHT", externalId: "live-key" },
]);
prismaMock.measurement.findMany
.mockResolvedValueOnce([
{ id: "m-live", type: "WEIGHT", externalId: "live-key", measuredAt },
])
.mockResolvedValueOnce([]); // natural-key rescue: no twin
const { imported } = await upsertFitbitMeasurements("user1", [
{
@@ -438,7 +489,7 @@ describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => {
// fresh → create branch. Second findMany is the natural-key rescue probe.
prismaMock.measurement.findMany
.mockResolvedValueOnce([
{ id: "m-live", type: "WEIGHT", externalId: "live-key" },
{ id: "m-live", type: "WEIGHT", externalId: "live-key", measuredAt },
])
.mockResolvedValueOnce([]); // natural-key rescue: no twin
prismaMock.measurement.createManyAndReturn.mockRejectedValueOnce(
@@ -478,8 +529,8 @@ describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => {
// throws; the loop must continue to the second, and the create still runs.
prismaMock.measurement.findMany
.mockResolvedValueOnce([
{ id: "m-live-1", type: "WEIGHT", externalId: "live-1" },
{ id: "m-live-2", type: "WEIGHT", externalId: "live-2" },
{ id: "m-live-1", type: "WEIGHT", externalId: "live-1", measuredAt },
{ id: "m-live-2", type: "WEIGHT", externalId: "live-2", measuredAt },
])
.mockResolvedValueOnce([]); // natural-key rescue: no twin
prismaMock.measurement.update
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more