mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on Berlin days whatever the user's zone, the mood and cycle crosstabs keyed their metric days in Berlin, the resting-pulse proxy defaulted to Berlin days, the briefing's opener rotated at Berlin midnight, and every assessment prompt labelled today's date as Berlin's. They now use the user's zone. Rollup months and years are labelled by their UTC start, which is how they are cut; read in a zone west of UTC the label named the month before. "Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in lib/tz/format is now the one definition: the second copy in mood/date-key, DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies are gone. An in-repo lint rule rejects the literal outside lib/tz and tests; four areas another change owns are listed as pending.
This commit is contained in:
1 parent
c366d4946c
commit
9d2fd1db14
124 files changed
+705
-415
No files matched your search
@@ -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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -101,6 +101,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.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }> };
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ 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";
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -175,7 +176,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,
|
||||
@@ -603,7 +604,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,7 +29,6 @@ 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/tz/local-day";
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,22 +9,14 @@
|
||||
* (jobs, API routes, PDF generation) pass the locale explicitly through
|
||||
* `makeFormatters(locale)`. The optional second argument carries the
|
||||
* per-user timezone (v1.4.25 W7) — when omitted the formatter falls back
|
||||
* to `DISPLAY_TIMEZONE` so legacy callers keep rendering in Europe/Berlin.
|
||||
* to `DEFAULT_TIMEZONE` so legacy callers keep rendering in the default zone.
|
||||
*
|
||||
* UTC is always preserved in the database.
|
||||
*/
|
||||
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { getDateTimeFormat, getNumberFormat } from "./intl/formatter-cache";
|
||||
import { isValidTimezone } from "./tz/format";
|
||||
|
||||
/**
|
||||
* Fallback timezone for surfaces that are not yet user-scoped (admin
|
||||
* tables, audit log viewer, anything without a resolved user). User-
|
||||
* scoped surfaces should call `makeFormatters(locale, userTz)` with
|
||||
* the value from `resolveUserTimezone()`.
|
||||
*/
|
||||
export const DISPLAY_TIMEZONE = "Europe/Berlin";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "./tz/format";
|
||||
|
||||
/**
|
||||
* Map our short locale ("de" / "en") to a full BCP-47 tag suitable for
|
||||
@@ -185,7 +177,7 @@ export interface Formatters {
|
||||
* `useFormatters()` silently took the default. A caller with nothing to
|
||||
* pass writes "AUTO" and thereby says so. Same for `userTz`: pass the
|
||||
* resolved profile zone, or an explicit `undefined` to accept the
|
||||
* `DISPLAY_TIMEZONE` fallback.
|
||||
* `DEFAULT_TIMEZONE` fallback.
|
||||
*/
|
||||
export function makeFormatters(
|
||||
locale: Locale,
|
||||
@@ -199,7 +191,7 @@ export function makeFormatters(
|
||||
// every page that renders a timestamp. Validate here (cheap — the
|
||||
// probe memoises successful constructions) and fall back to Berlin,
|
||||
// matching `formatInUserTz` / `hourInTz` / `isNearUtc`.
|
||||
const tz = userTz && isValidTimezone(userTz) ? userTz : DISPLAY_TIMEZONE;
|
||||
const tz = userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE;
|
||||
const hourOpts = hourCycleOptions(timeFormat);
|
||||
// Field-order locale for the numeric date renderers. AUTO keeps the
|
||||
// user's own locale; DMY/MDY/YMD pin a canonical locale whose default
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
bridgeFrozenStreakGaps,
|
||||
calculateLongestStreak,
|
||||
evaluateAchievementsWithCompletionDates,
|
||||
getUniqueBerlinDays,
|
||||
type AchievementMetrics,
|
||||
type AchievementProgress,
|
||||
type EarnabilityFlags,
|
||||
@@ -68,16 +67,6 @@ const NONE_EARNABLE: EarnabilityFlags = {
|
||||
};
|
||||
|
||||
describe("gamification achievements", () => {
|
||||
it("deduplicates and sorts day keys", () => {
|
||||
const days = getUniqueBerlinDays([
|
||||
new Date("2026-02-11T12:00:00Z"),
|
||||
new Date("2026-02-10T12:00:00Z"),
|
||||
new Date("2026-02-11T16:00:00Z"),
|
||||
]);
|
||||
|
||||
expect(days).toEqual(["2026-02-10", "2026-02-11"]);
|
||||
});
|
||||
|
||||
it("calculates the longest streak", () => {
|
||||
expect(
|
||||
calculateLongestStreak([
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getHiddenMetrics,
|
||||
getMoodMetrics,
|
||||
} from "@/lib/gamification/expansion-metrics";
|
||||
import { toBerlinDayKey } from "@/lib/gamification/achievements";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
// Mirror of the module-private Berlin-hour derivation so the parity test can
|
||||
// build a CORRECT precomputed `measurementHours` array.
|
||||
@@ -408,7 +408,7 @@ describe("achievements Intl precompute — byte-identical parity", () => {
|
||||
];
|
||||
|
||||
const measurementDayKeys = measurements.map((m) =>
|
||||
toBerlinDayKey(m.measuredAt),
|
||||
userDayKey(m.measuredAt, "Europe/Berlin"),
|
||||
);
|
||||
const measurementHours = measurements.map((m) => berlinHour(m.measuredAt));
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const BERLIN_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export const GAMIFICATION_ROLLOUT_AT = new Date("2026-02-20T00:00:00.000Z");
|
||||
|
||||
export type AchievementMetricKey =
|
||||
@@ -233,13 +231,6 @@ export interface AchievementSummary {
|
||||
nextAchievement: AchievementProgress | null;
|
||||
}
|
||||
|
||||
const DAY_KEY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: BERLIN_TIMEZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
const STREAK_TARGETS = [1, 7, 30, 180, 360] as const;
|
||||
|
||||
function buildStreakAchievements(config: {
|
||||
@@ -723,23 +714,6 @@ function dayKeyToNumber(dayKey: string): number {
|
||||
return Date.UTC(year, month - 1, day) / 86_400_000;
|
||||
}
|
||||
|
||||
export function toBerlinDayKey(date: Date): string {
|
||||
const parts = DAY_KEY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not format date");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getUniqueBerlinDays(dates: Date[]): string[] {
|
||||
return Array.from(new Set(dates.map((date) => toBerlinDayKey(date)))).sort();
|
||||
}
|
||||
|
||||
export function calculateLongestStreak(dayKeys: string[]): number {
|
||||
if (dayKeys.length === 0) return 0;
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { locales, defaultLocale, type Locale } from "./config";
|
||||
import {
|
||||
DISPLAY_TIMEZONE,
|
||||
makeFormatters,
|
||||
type Formatters,
|
||||
type TimeFormatPreference,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
primeMessages,
|
||||
type MessageBundle,
|
||||
} from "./load-locale";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
@@ -370,7 +370,7 @@ export function useDateFormatPreference(): DateFormatPreference {
|
||||
|
||||
/**
|
||||
* The user's mirrored profile timezone, resolved to a renderable IANA zone:
|
||||
* valid mirror value → `DISPLAY_TIMEZONE` (Europe/Berlin). Reactive to
|
||||
* valid mirror value → `DEFAULT_TIMEZONE` (Europe/Berlin). Reactive to
|
||||
* mirror changes (profile save, cross-tab, `/api/auth/me` re-fetch) via the
|
||||
* same localStorage-mirror pattern as the hour-cycle preference. SSR and the
|
||||
* pre-fetch state resolve Berlin — deliberately NO browser-timezone rung
|
||||
@@ -386,7 +386,7 @@ export function useDisplayTimezone(): string {
|
||||
readStoredTimezone,
|
||||
() => "",
|
||||
);
|
||||
return stored !== "" ? stored : DISPLAY_TIMEZONE;
|
||||
return stored !== "" ? stored : DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,7 +42,7 @@ import { prisma } from "@/lib/db";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
generateBiomarkerStatus,
|
||||
biomarkerStatusScope,
|
||||
@@ -142,7 +142,7 @@ describe("generateBiomarkerStatus — empty-data guard", () => {
|
||||
|
||||
describe("generateBiomarkerStatus — cache read", () => {
|
||||
it("serves today's cached text without calling the provider", async () => {
|
||||
const todayKey = toBerlinDayKey(new Date());
|
||||
const todayKey = userDayKey(new Date(), "Europe/Berlin");
|
||||
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
|
||||
noteRow({
|
||||
dateKey: todayKey,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
bucketSeries,
|
||||
dayOffsetToBerlinDayKey,
|
||||
dayOffsetToDayKey,
|
||||
} from "../bucket-series";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -75,7 +75,7 @@ describe("bucketSeries()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||
describe("dayOffsetToDayKey() across DST boundaries", () => {
|
||||
// Europe/Berlin DST in 2024: spring-forward at 2024-03-31 02:00 → 03:00,
|
||||
// fall-back at 2024-10-27 03:00 → 02:00. Naive `now − dayOffset·86_400_000`
|
||||
// would crawl past those boundaries by an hour and silently land on the
|
||||
@@ -85,8 +85,8 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||
it("produces today's Berlin date for dayOffset 0 around the spring-forward", () => {
|
||||
// 2024-03-31 11:30 Berlin (= 09:30 UTC, after the gap).
|
||||
const now = new Date("2024-03-31T09:30:00.000Z");
|
||||
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-03-31");
|
||||
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-03-30");
|
||||
expect(dayOffsetToDayKey(now, 0)).toBe("2024-03-31");
|
||||
expect(dayOffsetToDayKey(now, 1)).toBe("2024-03-30");
|
||||
});
|
||||
|
||||
it("crossing the spring-forward DST boundary stays on the correct Berlin calendar day", () => {
|
||||
@@ -96,10 +96,10 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||
// 11:30 CET → still 2024-03-30. That happens to look right here, so
|
||||
// the killer case is dayOffset 2 from a moment LATE on the day before
|
||||
// the gap.
|
||||
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-04-02");
|
||||
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-04-01");
|
||||
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-03-31");
|
||||
expect(dayOffsetToBerlinDayKey(now, 3)).toBe("2024-03-30");
|
||||
expect(dayOffsetToDayKey(now, 0)).toBe("2024-04-02");
|
||||
expect(dayOffsetToDayKey(now, 1)).toBe("2024-04-01");
|
||||
expect(dayOffsetToDayKey(now, 2)).toBe("2024-03-31");
|
||||
expect(dayOffsetToDayKey(now, 3)).toBe("2024-03-30");
|
||||
});
|
||||
|
||||
it("crossing the spring-forward boundary from late-evening Berlin time does not slip a day", () => {
|
||||
@@ -108,9 +108,9 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||
// 23:30 CET, which Intl formats as 2024-03-30. The CALENDAR-day
|
||||
// answer is 2024-03-31 — one Berlin calendar day earlier than today.
|
||||
const now = new Date("2024-03-31T22:30:00.000Z");
|
||||
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-04-01");
|
||||
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-03-31");
|
||||
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-03-30");
|
||||
expect(dayOffsetToDayKey(now, 0)).toBe("2024-04-01");
|
||||
expect(dayOffsetToDayKey(now, 1)).toBe("2024-03-31");
|
||||
expect(dayOffsetToDayKey(now, 2)).toBe("2024-03-30");
|
||||
});
|
||||
|
||||
it("crossing the fall-back boundary from late-evening Berlin time does not slip a day", () => {
|
||||
@@ -120,9 +120,9 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||
// 01:30 → 2024-10-27. That's actually correct by coincidence, so we
|
||||
// probe the dayOffset where the bias bites:
|
||||
const now = new Date("2024-10-27T23:30:00.000Z");
|
||||
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-10-28");
|
||||
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-10-27");
|
||||
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-10-26");
|
||||
expect(dayOffsetToDayKey(now, 0)).toBe("2024-10-28");
|
||||
expect(dayOffsetToDayKey(now, 1)).toBe("2024-10-27");
|
||||
expect(dayOffsetToDayKey(now, 2)).toBe("2024-10-26");
|
||||
});
|
||||
|
||||
it("crossing the fall-back boundary the other direction (early-morning) stays put", () => {
|
||||
@@ -131,9 +131,9 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||
// is what naive 24h subtraction gives because the day was 25 hours
|
||||
// long).
|
||||
const now = new Date("2024-10-27T01:30:00.000Z");
|
||||
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-10-27");
|
||||
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-10-26");
|
||||
expect(dayOffsetToBerlinDayKey(now, 7)).toBe("2024-10-20");
|
||||
expect(dayOffsetToDayKey(now, 0)).toBe("2024-10-27");
|
||||
expect(dayOffsetToDayKey(now, 1)).toBe("2024-10-26");
|
||||
expect(dayOffsetToDayKey(now, 7)).toBe("2024-10-20");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,16 +174,14 @@ describe("bucketSeries() honours the per-user timezone (M-TZ3)", () => {
|
||||
expect(berlin).toEqual(def);
|
||||
});
|
||||
|
||||
it("dayOffsetToBerlinDayKey resolves the offset in the passed timezone", () => {
|
||||
it("dayOffsetToDayKey resolves the offset in the passed timezone", () => {
|
||||
// `now` is 05-09 in LA but 05-10 in Berlin.
|
||||
expect(dayOffsetToBerlinDayKey(reading, 0, "America/Los_Angeles")).toBe(
|
||||
expect(dayOffsetToDayKey(reading, 0, "America/Los_Angeles")).toBe(
|
||||
"2026-05-09",
|
||||
);
|
||||
// Default (no tz) and explicit Berlin agree.
|
||||
expect(dayOffsetToBerlinDayKey(reading, 0)).toBe("2026-05-10");
|
||||
expect(dayOffsetToBerlinDayKey(reading, 0, "Europe/Berlin")).toBe(
|
||||
"2026-05-10",
|
||||
);
|
||||
expect(dayOffsetToDayKey(reading, 0)).toBe("2026-05-10");
|
||||
expect(dayOffsetToDayKey(reading, 0, "Europe/Berlin")).toBe("2026-05-10");
|
||||
});
|
||||
|
||||
it("applyPayloadBudget threads the timezone through to the buckets", () => {
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
|
||||
import type { ReadDayAggregatesOptions } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Readings spanning `days` back from `now`, `perDay` per day. */
|
||||
@@ -80,7 +82,7 @@ describe("buildGradedSeriesWithRollups — bounded reads (#1023)", () => {
|
||||
rows: [rollupRow(new Date("2025-09-01T00:00:00Z"), 79)],
|
||||
});
|
||||
|
||||
await buildGradedSeriesWithRollups("u1", "PULSE", now);
|
||||
await buildGradedSeriesWithRollups("u1", "PULSE", now, TZ);
|
||||
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
const opts = readDayAggregates.mock.calls[0][0] as ReadDayAggregatesOptions;
|
||||
@@ -99,7 +101,7 @@ describe("buildGradedSeriesWithRollups — bounded reads (#1023)", () => {
|
||||
serveFrom(points(800, now));
|
||||
readBestGranularityRollups.mockResolvedValue(null);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now, TZ);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
@@ -129,7 +131,7 @@ describe("buildGradedSeriesWithRollups — bounded reads (#1023)", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now, TZ);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
@@ -148,7 +150,7 @@ describe("buildGradedSeriesWithRollups — bounded reads (#1023)", () => {
|
||||
: null,
|
||||
);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now, TZ);
|
||||
|
||||
expect(series.monthly).toHaveLength(1);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
@@ -170,7 +172,7 @@ describe("buildGradedSeriesFromDayAggregates — parity with the raw fold", () =
|
||||
{ measuredAt: new Date(now.getTime() - 21 * dayMs), value: 99 },
|
||||
{ measuredAt: new Date(now.getTime() - 91 * dayMs), value: 41 },
|
||||
];
|
||||
const raw = buildGradedSeriesFromPoints(rows, now);
|
||||
const raw = buildGradedSeriesFromPoints(rows, now, TZ);
|
||||
const agg = buildGradedSeriesFromDayAggregates(
|
||||
foldDayAggregates(rows, {
|
||||
since: new Date(0),
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
function dailyPoints(
|
||||
@@ -34,7 +36,7 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
const now = new Date("2026-05-31T12:00:00Z");
|
||||
|
||||
it("keeps the last ~14-21 days at daily granularity", () => {
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now);
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now, TZ);
|
||||
expect(s.recent.length).toBeGreaterThan(0);
|
||||
expect(s.recent.length).toBeLessThanOrEqual(21);
|
||||
// Each recent bucket carries daily aggregates.
|
||||
@@ -51,7 +53,7 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
});
|
||||
|
||||
it("folds the weeks after the recent window into ~8-10 ISO-week buckets", () => {
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now);
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now, TZ);
|
||||
expect(s.weekly.length).toBeGreaterThan(0);
|
||||
expect(s.weekly.length).toBeLessThanOrEqual(12);
|
||||
for (const w of s.weekly) {
|
||||
@@ -64,7 +66,7 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
});
|
||||
|
||||
it("folds older history into ~9-12 monthly buckets", () => {
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now);
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now, TZ);
|
||||
expect(s.monthly.length).toBeGreaterThan(0);
|
||||
expect(s.monthly.length).toBeLessThanOrEqual(14);
|
||||
for (const m of s.monthly) {
|
||||
@@ -77,7 +79,7 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
|
||||
it("folds multi-year history into yearly buckets with a trend slope", () => {
|
||||
// 3+ years so at least one year lands in the yearly bucket.
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(1200, now), now);
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(1200, now), now, TZ);
|
||||
expect(s.yearly.length).toBeGreaterThan(0);
|
||||
for (const y of s.yearly) {
|
||||
expect(y).toHaveProperty("year");
|
||||
@@ -87,7 +89,7 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
});
|
||||
|
||||
it("collapses a 2-year daily weigher into <= ~50 buckets, not 730", () => {
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now);
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now, TZ);
|
||||
// ~21 recent + ~10 weekly + ~12 monthly + ~2-3 yearly. Worst-case
|
||||
// boundary slack lands ~46; the point is the order-of-magnitude
|
||||
// collapse from 730 daily readings, not an exact bucket count.
|
||||
@@ -95,12 +97,12 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
});
|
||||
|
||||
it("returns empty buckets for an empty input", () => {
|
||||
const s = buildGradedSeriesFromPoints([], now);
|
||||
const s = buildGradedSeriesFromPoints([], now, TZ);
|
||||
expect(countBuckets(s)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not duplicate a day across recent and weekly", () => {
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(120, now), now);
|
||||
const s = buildGradedSeriesFromPoints(dailyPoints(120, now), now, TZ);
|
||||
// The most recent ISO week of the recent window must not also be a
|
||||
// weekly bucket — recent days are excluded from the weekly fold.
|
||||
const recentDates = new Set(s.recent.map((r) => r.date));
|
||||
@@ -115,7 +117,7 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
{ measuredAt: new Date(now.getTime() - 1000), value: 90 },
|
||||
{ measuredAt: new Date(now.getTime() - 2000), value: 80 },
|
||||
];
|
||||
const s = buildGradedSeriesFromPoints(points, now);
|
||||
const s = buildGradedSeriesFromPoints(points, now, TZ);
|
||||
expect(s.recent.length).toBe(1);
|
||||
expect(s.recent[0].min).toBe(70);
|
||||
expect(s.recent[0].max).toBe(90);
|
||||
@@ -174,9 +176,27 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
const points = reconstructSleepNights(rows, "UTC")
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
|
||||
const graded = buildGradedSeriesFromPoints(points, sleepNow);
|
||||
const graded = buildGradedSeriesFromPoints(points, sleepNow, TZ);
|
||||
const recentMean = graded.recent.at(-1)?.mean ?? 0;
|
||||
expect(recentMean).toBe(480); // night total, not ~1218 (stage sum)
|
||||
expect(recentMean).toBeLessThan(960); // < 16 h — never impossible
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGradedSeriesFromPoints — the user's calendar", () => {
|
||||
it.each([
|
||||
// 23:30 on 9 June in New York is already 10 June in UTC and Berlin.
|
||||
["America/New_York", "2026-06-10T03:30:00.000Z", "2026-06-09"],
|
||||
// 08:00 on 10 June in Auckland and Tonga is still 9 June in UTC.
|
||||
["Pacific/Auckland", "2026-06-09T20:00:00.000Z", "2026-06-10"],
|
||||
["Pacific/Tongatapu", "2026-06-09T19:00:00.000Z", "2026-06-10"],
|
||||
])("keys a reading on its local day in %s", (tz, iso, day) => {
|
||||
const now = new Date("2026-06-12T12:00:00.000Z");
|
||||
const s = buildGradedSeriesFromPoints(
|
||||
[{ measuredAt: new Date(iso), value: 70 }],
|
||||
now,
|
||||
tz,
|
||||
);
|
||||
expect(s.recent.map((r) => r.date)).toEqual([day]);
|
||||
});
|
||||
});
|
||||
@@ -1006,7 +1006,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
meas(i, "ACTIVE_ENERGY_BURNED", 350 + (i % 2 === 0 ? 20 : -20)),
|
||||
);
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find(
|
||||
(r) => r.tag === "worked_out" && r.metricKey === "activeEnergy",
|
||||
);
|
||||
@@ -1045,7 +1050,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
meas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6)),
|
||||
);
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find((r) => r.metricKey === "sleepDuration");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.display).toBe("hours");
|
||||
@@ -1079,7 +1089,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
meas(i - 1, "RECOVERY_SCORE", 80 + (i % 2 === 0 ? 3 : -3)),
|
||||
);
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find((r) => r.metricKey === "nextDayRecovery");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.mode).toBe("nextDay");
|
||||
@@ -1134,7 +1149,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
62,
|
||||
54,
|
||||
);
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find(
|
||||
(r) => r.tag === "alcohol" && r.metricKey === "nextDayRestingHeartRate",
|
||||
);
|
||||
@@ -1156,7 +1176,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
38,
|
||||
55,
|
||||
);
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find(
|
||||
(r) =>
|
||||
r.tag === "alcohol" && r.metricKey === "nextDayHeartRateVariability",
|
||||
@@ -1183,6 +1208,7 @@ describe("computeTagMetricCrosstab", () => {
|
||||
{ key, categoryKey: "health", labelKey, icon },
|
||||
);
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
@@ -1220,7 +1246,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350));
|
||||
}
|
||||
expect(CROSSTAB_MIN_PRESENT_DAYS).toBeGreaterThan(3);
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
expect(rows.find((r) => r.tag === "worked_out")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1235,7 +1266,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
entries.push(entry(i, 3, []));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1245,6 +1281,7 @@ describe("computeTagMetricCrosstab", () => {
|
||||
entries.push(structuredEntry(i, 4, i < 12 ? [WORKOUT] : []));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements: [],
|
||||
now: NOW,
|
||||
@@ -1301,7 +1338,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find(
|
||||
(r) => r.tag === "worked_out" && r.metricKey === "activeEnergy",
|
||||
);
|
||||
@@ -1335,7 +1377,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
sourcedMeas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6), "WHOOP"),
|
||||
);
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find((r) => r.metricKey === "sleepDuration");
|
||||
expect(row).toBeDefined();
|
||||
// 8 h, not 16 h — the Fitbit twin is dropped, not summed on top.
|
||||
@@ -1370,7 +1417,12 @@ describe("computeTagMetricCrosstab", () => {
|
||||
measurements.push(sourcedMeas(i, "SLEEP_DURATION", 120 + j, "WHOOP"));
|
||||
measurements.push(sourcedMeas(i, "SLEEP_DURATION", 120 + j, "WHOOP"));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const rows = computeTagMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
});
|
||||
const row = rows.find((r) => r.metricKey === "sleepDuration");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.withAvg).toBeCloseTo(8, 1); // 3 × 160 = 480 min = 8 h
|
||||
@@ -1478,6 +1530,7 @@ describe("computeFactorMetricCrosstab", () => {
|
||||
);
|
||||
}
|
||||
const rows = computeFactorMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
@@ -1517,6 +1570,7 @@ describe("computeFactorMetricCrosstab", () => {
|
||||
);
|
||||
}
|
||||
const rows = computeFactorMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
@@ -1543,6 +1597,7 @@ describe("computeFactorMetricCrosstab", () => {
|
||||
CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS,
|
||||
).toBeGreaterThan(6);
|
||||
const rows = computeFactorMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
@@ -1558,7 +1613,12 @@ describe("computeFactorMetricCrosstab", () => {
|
||||
measurements.push(meas(i, "SLEEP_DURATION", 420));
|
||||
}
|
||||
expect(
|
||||
computeFactorMetricCrosstab({ entries, measurements, now: NOW }),
|
||||
computeFactorMetricCrosstab({
|
||||
timeZone: "Europe/Berlin",
|
||||
entries,
|
||||
measurements,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
dayOffsetToBerlinDayKey,
|
||||
dayOffsetToDayKey,
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
@@ -67,6 +67,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
|
||||
/**
|
||||
* Cap on the embedded correlation / paired-daily arrays. The Pearson
|
||||
@@ -78,7 +79,7 @@ const CORRELATION_PAIR_CAP = 30;
|
||||
/**
|
||||
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
|
||||
* `dayOffsetToDayKey()` is the source of truth so DST boundaries
|
||||
* don't slip the day-key by one. Each pair also carries `dayKey`
|
||||
* directly so callers can label points without re-formatting.
|
||||
*/
|
||||
@@ -94,10 +95,9 @@ function pairDailyBuckets(
|
||||
.map((entry) => {
|
||||
const b = mapB.get(entry.dayOffset);
|
||||
if (b == null) return null;
|
||||
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz);
|
||||
// UTC midnight of the Berlin day — formatting this Date with
|
||||
// `toBerlinDayKey()` is guaranteed DST-safe because the y-m-d
|
||||
// fields below are the Berlin calendar day fields by construction.
|
||||
const dayKey = dayOffsetToDayKey(now, entry.dayOffset, tz);
|
||||
// UTC midnight of the user's day: an ordering handle only; `dayKey`
|
||||
// is the day itself.
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
return {
|
||||
a: entry.value,
|
||||
@@ -435,8 +435,8 @@ export async function prepareBloodPressureStatusForUser(
|
||||
}
|
||||
|
||||
const continuityVsSystolicSeries = sysSeries.daily.map((point) => {
|
||||
// DST-safe: dayOffsetToBerlinDayKey computes calendar days, not 24h ticks.
|
||||
const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset, userTz);
|
||||
// DST-safe: dayOffsetToDayKey computes calendar days, not 24h ticks.
|
||||
const dayKey = dayOffsetToDayKey(now, point.dayOffset, userTz);
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuityPct =
|
||||
expectedBpIntakesPerDay > 0
|
||||
@@ -480,18 +480,22 @@ export async function prepareBloodPressureStatusForUser(
|
||||
const gradedFromDaily = (buckets: DailyBucket[]) =>
|
||||
buildGradedSeriesFromPoints(
|
||||
buckets.map((b) => ({
|
||||
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset, userTz)),
|
||||
measuredAt: canonicalDailyTimestamp(
|
||||
dayOffsetToDayKey(now, b.dayOffset, userTz),
|
||||
userTz,
|
||||
),
|
||||
value: b.value,
|
||||
})),
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
// The two BP channels source their recent / weekly slices from a
|
||||
// bounded raw read and their monthly / yearly tail from the MONTH /
|
||||
// YEAR rollup tier (full-history in-memory fallback on a cold-tier
|
||||
// coverage miss). Mood has no rollup tier, so it stays an in-memory fold.
|
||||
const [sysGraded, diaGraded] = await Promise.all([
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now, userTz),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now, userTz),
|
||||
]);
|
||||
const moodGraded = gradedFromDaily(moodSeries.daily);
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ export async function prepareBmiStatusForUser(
|
||||
userId,
|
||||
"WEIGHT",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2);
|
||||
const bmiSeries = {
|
||||
|
||||
@@ -96,7 +96,7 @@ function utcMidnightOfUserDay(date: Date, tz: string): number {
|
||||
* `tz` defaults to Berlin only so legacy callers keep working; per-user
|
||||
* surfaces pass the resolved `User.timezone`.
|
||||
*/
|
||||
export function dayOffsetToBerlinDayKey(
|
||||
export function dayOffsetToDayKey(
|
||||
now: Date,
|
||||
dayOffset: number,
|
||||
tz: string = DEFAULT_TIMEZONE,
|
||||
|
||||
@@ -886,6 +886,7 @@ export async function generateComprehensiveInsight(
|
||||
userId,
|
||||
dbUser?.displayName ?? null,
|
||||
locale,
|
||||
await resolveUserTimezone(userId),
|
||||
);
|
||||
const systemPrompt = buildSystemPromptWithReferences(
|
||||
locale,
|
||||
|
||||
@@ -218,7 +218,7 @@ export async function prepareGeneralStatusForUser(
|
||||
|
||||
const series = applyPayloadBudget(records, { now, tz: userTz });
|
||||
dailyByType.set(type, series);
|
||||
const graded = buildGradedSeriesFromPoints(records, now);
|
||||
const graded = buildGradedSeriesFromPoints(records, now, userTz);
|
||||
|
||||
return [
|
||||
[
|
||||
@@ -298,7 +298,11 @@ export async function prepareGeneralStatusForUser(
|
||||
now,
|
||||
tz: userTz,
|
||||
});
|
||||
const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now);
|
||||
const adherenceGraded = buildGradedSeriesFromPoints(
|
||||
adherenceRecords,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// Fetch mood context (optional — for enrichment only). v1.4.28
|
||||
// FB-D2 — cap at 90 entries.
|
||||
@@ -316,7 +320,7 @@ export async function prepareGeneralStatusForUser(
|
||||
value: entry.score,
|
||||
}));
|
||||
const moodSeries = applyPayloadBudget(moodRecords, { now, tz: userTz });
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now);
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now, userTz);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
readDayAggregates,
|
||||
type DayAggregateRow,
|
||||
} from "@/lib/measurements/day-aggregates";
|
||||
import { toBerlinYmd } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -87,7 +87,7 @@ const MONTHLY_WINDOW_MS = WEEKLY_WINDOW_MS + MONTHLY_MONTHS * 30 * MS_PER_DAY;
|
||||
const COLD_FALLBACK_WINDOW_DAYS = 1095;
|
||||
|
||||
export interface RecentDayBucket {
|
||||
/** Berlin YYYY-MM-DD. */
|
||||
/** YYYY-MM-DD in the user's zone. */
|
||||
date: string;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -96,7 +96,7 @@ export interface RecentDayBucket {
|
||||
}
|
||||
|
||||
export interface WeeklyBucket {
|
||||
/** ISO week key like 2026-W19 (Berlin-anchored). */
|
||||
/** ISO week key like 2026-W19 (the user's calendar). */
|
||||
weekISO: string;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -105,7 +105,7 @@ export interface WeeklyBucket {
|
||||
}
|
||||
|
||||
export interface MonthlyBucket {
|
||||
/** Berlin YYYY-MM. */
|
||||
/** YYYY-MM (the user's calendar; UTC for rollup months). */
|
||||
month: string;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -114,7 +114,7 @@ export interface MonthlyBucket {
|
||||
}
|
||||
|
||||
export interface YearlyBucket {
|
||||
/** Berlin YYYY. */
|
||||
/** YYYY (the user's calendar; UTC for rollup years). */
|
||||
year: string;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -141,28 +141,13 @@ function round(value: number, digits = 2): number {
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
const { year, month, day } = toBerlinYmd(date);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function berlinMonthKey(date: Date): string {
|
||||
const { year, month } = toBerlinYmd(date);
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
|
||||
function berlinYearKey(date: Date): string {
|
||||
return toBerlinYmd(date).year;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO week key (Berlin-anchored). Mirrors the Coach's `isoWeekKey` but
|
||||
* fixed to the user's Berlin display day so the week label agrees with
|
||||
* the rest of the snapshot.
|
||||
*/
|
||||
function berlinIsoWeekKey(date: Date): string {
|
||||
const { year, month, day } = toBerlinYmd(date);
|
||||
return isoWeekKeyOfYmd(year, month, day);
|
||||
/** The `YYYY-MM-DD` day `date` falls on in `timeZone`, as its parts. */
|
||||
function localYmd(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
): { year: string; month: string; day: string } {
|
||||
const [year, month, day] = userDayKey(date, timeZone).split("-");
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
/** ISO week key of a calendar date given as `YYYY`, `MM`, `DD`. */
|
||||
@@ -228,6 +213,7 @@ function leastSquaresSlope(values: number[]): number | null {
|
||||
export function buildGradedSeriesFromPoints(
|
||||
points: Point[],
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): GradedSeries {
|
||||
const nowMs = now.getTime();
|
||||
|
||||
@@ -242,14 +228,16 @@ export function buildGradedSeriesFromPoints(
|
||||
const ageMs = nowMs - p.measuredAt.getTime();
|
||||
if (ageMs < 0) continue; // future reading — ignore
|
||||
|
||||
// Every bucket is a period of the user's own calendar.
|
||||
const { year, month, day } = localYmd(p.measuredAt, timeZone);
|
||||
if (ageMs < RECENT_WINDOW_MS) {
|
||||
foldInto(recentAgg, berlinDayKey(p.measuredAt), p.value);
|
||||
foldInto(recentAgg, `${year}-${month}-${day}`, p.value);
|
||||
} else if (ageMs < WEEKLY_WINDOW_MS) {
|
||||
foldInto(weeklyAgg, berlinIsoWeekKey(p.measuredAt), p.value);
|
||||
foldInto(weeklyAgg, isoWeekKeyOfYmd(year, month, day), p.value);
|
||||
} else if (ageMs < MONTHLY_WINDOW_MS) {
|
||||
foldInto(monthlyAgg, berlinMonthKey(p.measuredAt), p.value);
|
||||
foldInto(monthlyAgg, `${year}-${month}`, p.value);
|
||||
} else {
|
||||
const yk = berlinYearKey(p.measuredAt);
|
||||
const yk = year;
|
||||
foldInto(yearlyAgg, yk, p.value);
|
||||
const list = yearlyValues.get(yk);
|
||||
if (list) list.push(p.value);
|
||||
@@ -336,7 +324,7 @@ function gradedSegmentStarts(now: Date): Date[] {
|
||||
*
|
||||
* Replaces the raw `findMany` over the window (#1023): a watch that streams
|
||||
* heart rate every few seconds put a million rows into that read, which is
|
||||
* what exhausted the worker's heap. The rows here are one per Berlin day per
|
||||
* what exhausted the worker's heap. The rows here are one per local day per
|
||||
* age segment, and their sum / count / min / max are exact, so the recent,
|
||||
* weekly and monthly slices come out identical to folding the raw rows.
|
||||
*
|
||||
@@ -349,6 +337,7 @@ async function readGradedDayAggregates(
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): Promise<DayAggregateRow[]> {
|
||||
return readDayAggregates({
|
||||
userId,
|
||||
@@ -356,7 +345,7 @@ async function readGradedDayAggregates(
|
||||
since,
|
||||
// Future-dated readings were skipped by the in-memory fold.
|
||||
until: now,
|
||||
timeZone: "Europe/Berlin",
|
||||
timeZone,
|
||||
// `readDayAggregates` puts a row at segment k when it is older than k
|
||||
// starts (`measured_at < start`). The in-memory fold treats an age of
|
||||
// exactly the window as the older bucket, so shift each start by 1 ms.
|
||||
@@ -435,10 +424,12 @@ export function buildGradedSeriesFromDayAggregates(
|
||||
/**
|
||||
* Project a rollup bucket row to a monthly/yearly graded bucket. The
|
||||
* tier already carries min/max/mean/slope per bucket — no JS folding.
|
||||
* Rollup buckets are UTC months and years, so the label is read off the
|
||||
* bucket start in UTC: read in a zone west of UTC it named the month before.
|
||||
*/
|
||||
function rollupMonthly(rows: RollupBucketRow[]): MonthlyBucket[] {
|
||||
return rows.map((r) => ({
|
||||
month: berlinMonthKey(r.bucketStart),
|
||||
month: r.bucketStart.toISOString().slice(0, 7),
|
||||
min: round(r.minValue),
|
||||
max: round(r.maxValue),
|
||||
mean: round(r.mean),
|
||||
@@ -448,7 +439,7 @@ function rollupMonthly(rows: RollupBucketRow[]): MonthlyBucket[] {
|
||||
|
||||
function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
|
||||
return rows.map((r) => ({
|
||||
year: berlinYearKey(r.bucketStart),
|
||||
year: r.bucketStart.toISOString().slice(0, 4),
|
||||
min: round(r.minValue),
|
||||
max: round(r.maxValue),
|
||||
mean: round(r.mean),
|
||||
@@ -485,10 +476,11 @@ export async function buildGradedSeriesWithRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): Promise<GradedSeries> {
|
||||
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
|
||||
const recentGraded = buildGradedSeriesFromDayAggregates(
|
||||
await readGradedDayAggregates(userId, type, since, now),
|
||||
await readGradedDayAggregates(userId, type, since, now, timeZone),
|
||||
);
|
||||
|
||||
// v1.11.2 — load the source-priority blob once and thread it into both
|
||||
@@ -548,7 +540,7 @@ export async function buildGradedSeriesWithRollups(
|
||||
now.getTime() - COLD_FALLBACK_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const fullGraded = buildGradedSeriesFromDayAggregates(
|
||||
await readGradedDayAggregates(userId, type, fallbackSince, now),
|
||||
await readGradedDayAggregates(userId, type, fallbackSince, now, timeZone),
|
||||
);
|
||||
if (!monthlyCovered) monthly = fullGraded.monthly;
|
||||
if (!yearlyCovered) yearly = fullGraded.yearly;
|
||||
|
||||
@@ -310,7 +310,7 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
now,
|
||||
tz: userTz,
|
||||
});
|
||||
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now);
|
||||
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now, userTz);
|
||||
const latestDay = dailyBudgeted.daily[0] ?? null;
|
||||
|
||||
// v1.18.10 (HIGH-4) — the finished recent-vs-baseline adherence comparison
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
import type { RelevantCorrelation } from "@/lib/insights/assessment-context";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Trailing window for the discovery scan — mirrors the route. */
|
||||
@@ -92,7 +93,7 @@ export async function getRelevantCorrelationsForMetric(
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
// The channel set is the shared one — every family the correlations page
|
||||
|
||||
@@ -308,11 +308,12 @@ export async function generateMetricStatus(args: {
|
||||
// straight from the deduped per-night TIME-ASLEEP `points` instead, so every
|
||||
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
|
||||
const graded = isSleep
|
||||
? buildGradedSeriesFromPoints(points, now)
|
||||
? buildGradedSeriesFromPoints(points, now, userTz)
|
||||
: await buildGradedSeriesWithRollups(
|
||||
args.userId,
|
||||
meta.measurementType,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const summary = summarizeSeries(
|
||||
series.daily.map((bucket) => ({ value: bucket.value })),
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
dayOffsetToBerlinDayKey,
|
||||
dayOffsetToDayKey,
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import { round, summarizeSeries } from "@/lib/insights/status-shared";
|
||||
@@ -294,7 +294,7 @@ export const MOOD_ORANGE_MAX = 3.5;
|
||||
/**
|
||||
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
|
||||
* `dayOffsetToDayKey()` is the source of truth so DST boundaries
|
||||
* don't slip the day-key by one. Each pair also carries `dayKey`
|
||||
* directly so callers can label points without re-formatting.
|
||||
*/
|
||||
@@ -310,7 +310,7 @@ export function pairDailyBuckets(
|
||||
.map((entry) => {
|
||||
const b = mapB.get(entry.dayOffset);
|
||||
if (b == null) return null;
|
||||
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz);
|
||||
const dayKey = dayOffsetToDayKey(now, entry.dayOffset, tz);
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
return {
|
||||
a: entry.value,
|
||||
@@ -626,12 +626,14 @@ export function computeMoodAggregates(args: {
|
||||
measurements,
|
||||
now,
|
||||
userPriorityJson,
|
||||
timeZone: tz,
|
||||
});
|
||||
const factorCrosstab = computeFactorMetricCrosstab({
|
||||
entries,
|
||||
measurements,
|
||||
now,
|
||||
userPriorityJson,
|
||||
timeZone: tz,
|
||||
});
|
||||
|
||||
// v1.38 — every context value against the day's pleasantness, one BH-FDR
|
||||
@@ -682,7 +684,7 @@ export function computeMoodAggregates(args: {
|
||||
a4: entry.a4 ?? null,
|
||||
a5: entry.a5 ?? null,
|
||||
})),
|
||||
dayOffsetToBerlinDayKey(now, 0, tz),
|
||||
dayOffsetToDayKey(now, 0, tz),
|
||||
),
|
||||
weekday,
|
||||
timeOfDay,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { welchTTest } from "@/lib/insights/correlations";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { MS_PER_DAY } from "@/lib/time-constants";
|
||||
import { toBerlinYmd } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import type {
|
||||
CrossMetricMeasurement,
|
||||
MoodAggregateEntry,
|
||||
@@ -181,14 +181,9 @@ const CROSSTAB_SUM_TYPES = new Set<string>([
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
|
||||
/** Berlin-calendar day key (`YYYY-MM-DD`) for a row's `measuredAt`. */
|
||||
function berlinDayKey(measuredAt: Date): string {
|
||||
const { year, month, day } = toBerlinYmd(measuredAt);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Berlin-day-keyed metric map with the right aggregation. Energy
|
||||
* Build a metric map keyed by the user's local day, the calendar mood
|
||||
* entries are dated on, with the right aggregation. Energy
|
||||
* and step totals are SUMMED per day (HealthKit `stats:` rows are already
|
||||
* one daily-total row, so the sum is the total either way); sleep duration
|
||||
* is SUMMED across per-stage rows to get the night total; everything else
|
||||
@@ -198,7 +193,7 @@ function berlinDayKey(measuredAt: Date): string {
|
||||
* Cross-source de-dup: before bucketing, the rows for this metric run
|
||||
* through the SAME canonical-source picker the analytics steps/sleep path
|
||||
* uses (`pickCanonicalSourceRows`, keyed by `metricKeyForType` + the
|
||||
* Berlin day key). Without it, the moment two sources report the same day
|
||||
* local day key). Without it, the moment two sources report the same day
|
||||
* (Fitbit + Apple steps, Fitbit + WHOOP sleep) the SUM channels would
|
||||
* double-count and bias the Welch delta. The picker collapses each day to
|
||||
* one source (and one device-type within it), so the sum reflects one
|
||||
@@ -211,7 +206,10 @@ export function metricDayMap(
|
||||
measurements: CrossMetricMeasurement[],
|
||||
type: string,
|
||||
userPriorityJson: unknown,
|
||||
/** The user's zone: days are the user's own calendar days. */
|
||||
timeZone: string,
|
||||
): Map<string, number> {
|
||||
const dayKeyOf = (d: Date) => userDayKey(d, timeZone);
|
||||
const summed = CROSSTAB_SUM_TYPES.has(type);
|
||||
|
||||
const typeRows = measurements.filter((m) => m.type === type);
|
||||
@@ -235,13 +233,13 @@ export function metricDayMap(
|
||||
})),
|
||||
metricKey,
|
||||
userPriorityJson,
|
||||
berlinDayKey,
|
||||
dayKeyOf,
|
||||
).canonicalRows
|
||||
: typeRows;
|
||||
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of canonicalRows) {
|
||||
const key = berlinDayKey(m.measuredAt);
|
||||
const key = dayKeyOf(m.measuredAt);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += m.value;
|
||||
cur.count += m.count ?? 1;
|
||||
@@ -297,6 +295,8 @@ export function computeTagMetricCrosstab(args: {
|
||||
* test default) resolves to the default ladders.
|
||||
*/
|
||||
userPriorityJson?: unknown;
|
||||
/** The user's zone, the calendar the metric days are cut on. */
|
||||
timeZone: string;
|
||||
}): TagMetricCrosstabRow[] {
|
||||
const { entries, measurements, now } = args;
|
||||
const windowDays = args.windowDays ?? 365;
|
||||
@@ -319,7 +319,12 @@ export function computeTagMetricCrosstab(args: {
|
||||
for (const [metricKey, cfg] of Object.entries(CROSSTAB_METRICS) as Array<
|
||||
[CrosstabMetricKey, (typeof CROSSTAB_METRICS)[CrosstabMetricKey]]
|
||||
>) {
|
||||
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
|
||||
const metricByDay = metricDayMap(
|
||||
measurements,
|
||||
cfg.type,
|
||||
userPriorityJson,
|
||||
args.timeZone,
|
||||
);
|
||||
if (metricByDay.size === 0) continue;
|
||||
|
||||
for (const [tagKey, ref] of structuredMeta) {
|
||||
@@ -609,6 +614,8 @@ export function computeFactorMetricCrosstab(args: {
|
||||
now: Date;
|
||||
windowDays?: number;
|
||||
userPriorityJson?: unknown;
|
||||
/** The user's zone, the calendar the metric days are cut on. */
|
||||
timeZone: string;
|
||||
}): FactorMetricCrosstabRow[] {
|
||||
const { entries, measurements, now } = args;
|
||||
const windowDays = args.windowDays ?? 365;
|
||||
@@ -627,7 +634,12 @@ export function computeFactorMetricCrosstab(args: {
|
||||
(typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey],
|
||||
]
|
||||
>) {
|
||||
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
|
||||
const metricByDay = metricDayMap(
|
||||
measurements,
|
||||
cfg.type,
|
||||
userPriorityJson,
|
||||
args.timeZone,
|
||||
);
|
||||
if (metricByDay.size === 0) continue;
|
||||
|
||||
for (const [factorKey, series] of factorSeries) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* "no correlation" filler.
|
||||
*/
|
||||
|
||||
import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series";
|
||||
import { dayOffsetToDayKey } from "@/lib/insights/bucket-series";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
import { MS_PER_DAY } from "@/lib/time-constants";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
@@ -120,7 +120,7 @@ function weekdayOfOffset(
|
||||
dayOffset: number,
|
||||
tz: string = DEFAULT_TIMEZONE,
|
||||
): number {
|
||||
const dayKey = dayOffsetToBerlinDayKey(now, dayOffset, tz);
|
||||
const dayKey = dayOffsetToDayKey(now, dayOffset, tz);
|
||||
const d = new Date(dayKey + "T00:00:00Z");
|
||||
return (d.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* `fetchMoodAggregates`.
|
||||
*/
|
||||
|
||||
import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series";
|
||||
import { dayOffsetToDayKey } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
MOOD_GREEN_MAX,
|
||||
MOOD_GREEN_MIN,
|
||||
@@ -87,7 +87,7 @@ export function computeWeekdayAverages(
|
||||
): WeekdayRow[] {
|
||||
const sums = new Map<number, { sum: number; count: number }>();
|
||||
for (const bucket of daily) {
|
||||
const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, tz);
|
||||
const dayKey = dayOffsetToDayKey(now, bucket.dayOffset, tz);
|
||||
const d = new Date(dayKey + "T00:00:00Z");
|
||||
const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0
|
||||
const cur = sums.get(weekday) ?? { sum: 0, count: 0 };
|
||||
|
||||
@@ -305,7 +305,7 @@ export async function prepareMoodStatusForUser(
|
||||
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
|
||||
// and correlations; the compact graded series reaches the prompt.
|
||||
const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz });
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now);
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now, userTz);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
@@ -490,6 +490,7 @@ export async function prepareMoodStatusForUser(
|
||||
measurements,
|
||||
now,
|
||||
userPriorityJson,
|
||||
timeZone: userTz,
|
||||
});
|
||||
|
||||
// v1.8.6 — the same threshold-gated narrative feed the user sees on
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
validateNarrativeText,
|
||||
buildNarrativeCorrection,
|
||||
} from "@/lib/insights/narrative/narrative-grounding";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Stable identifier for the narrative prompt revision. Bumped whenever the
|
||||
@@ -347,7 +348,7 @@ export async function generatePeriodNarrative(
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const completion = await runCompletion({
|
||||
userId,
|
||||
|
||||
@@ -55,6 +55,7 @@ import { buildBaselineBand, median } from "@/lib/insights/derived/baseline";
|
||||
import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry";
|
||||
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -480,7 +481,7 @@ export async function buildPeriodNarrativeContext(
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const currentFrom = tzDayKey(
|
||||
new Date(now.getTime() - periodDays * MS_PER_DAY),
|
||||
|
||||
@@ -233,7 +233,12 @@ export async function preparePulseStatusForUser(
|
||||
// Primary metric: recent / weekly fold from the bounded raw read, the
|
||||
// monthly / yearly tail comes from the MONTH / YEAR rollup tier (with
|
||||
// a full-history in-memory fallback on a cold-tier coverage miss).
|
||||
const pulseGraded = await buildGradedSeriesWithRollups(userId, "PULSE", now);
|
||||
const pulseGraded = await buildGradedSeriesWithRollups(
|
||||
userId,
|
||||
"PULSE",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const pulseSummary = summarizeSeries(
|
||||
pulseSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
@@ -255,7 +260,7 @@ export async function preparePulseStatusForUser(
|
||||
value: entry.score,
|
||||
}));
|
||||
const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz });
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now);
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now, userTz);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user