mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'fix/aggregation-day-boundaries' into release/v1.39.3
# Conflicts: # src/app/api/medications/[id]/route.ts
This commit is contained in:
commit
e2814336e5
208 files changed
+4612
-2164
No files matched your search
+10
-7
@@ -55355,8 +55355,8 @@ components:
|
||||
description: Trailing-window length in days for the chosen range.
|
||||
granularity:
|
||||
type: string
|
||||
description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage
|
||||
miss).
|
||||
description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`), `live` for SLEEP_DURATION
|
||||
(nights are reconstructed from the stage rows), or `none` when there is no data.
|
||||
current:
|
||||
description: Aggregate over the current window `[now-N, now)`.
|
||||
$ref: "#/components/schemas/AnalyticsWindowAggregate"
|
||||
@@ -55383,9 +55383,10 @@ components:
|
||||
- delta
|
||||
- deltaPct
|
||||
additionalProperties: false
|
||||
description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY
|
||||
rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across
|
||||
buckets; SD/slope/r² are intentionally excluded (not composable).
|
||||
description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the
|
||||
rollup tier and composes a count-weighted-mean delta, one source per day. Step-like totals compare average daily
|
||||
totals and SLEEP_DURATION compares average nights, not per-reading means. `count/min/max/mean/sum` are linearly
|
||||
composable across buckets; SD/slope/r² are intentionally excluded (not composable).
|
||||
AnalyticsWindowAggregate:
|
||||
type: object
|
||||
properties:
|
||||
@@ -55393,7 +55394,8 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Reading count composed across buckets.
|
||||
description: Reading count composed across buckets. For step-like totals it is the number of days with a total, for
|
||||
SLEEP_DURATION the number of nights.
|
||||
min:
|
||||
anyOf:
|
||||
- type: number
|
||||
@@ -55408,7 +55410,8 @@ components:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: Count-weighted mean across buckets; null when empty.
|
||||
description: Count-weighted mean across buckets; null when empty. For step-like totals (steps, energy, distance,
|
||||
flights) it is the average daily total, for SLEEP_DURATION the average time asleep per night.
|
||||
sum:
|
||||
anyOf:
|
||||
- type: number
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Unit tests for the `healthlog/no-default-zone-literal` rule: the literal is
|
||||
* banned in application source, allowed in `src/lib/tz/` and in tests, and
|
||||
* the pending files are exempt until their owner switches them.
|
||||
*/
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import rule from "../no-default-zone-literal.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run("no-default-zone-literal", rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const tz = user.timezone || DEFAULT_TIMEZONE;',
|
||||
filename: "/repo/src/app/api/insights/cards/route.ts",
|
||||
},
|
||||
{
|
||||
// The one definition.
|
||||
code: 'export const DEFAULT_TIMEZONE = "Europe/Berlin";',
|
||||
filename: "/repo/src/lib/tz/format.ts",
|
||||
},
|
||||
{
|
||||
// Fixtures pick a concrete zone on purpose.
|
||||
code: 'const TZ = "Europe/Berlin";',
|
||||
filename: "/repo/src/lib/insights/__tests__/graded-series.test.ts",
|
||||
},
|
||||
{
|
||||
// Comments are not checked.
|
||||
code: "// read on Europe/Berlin days before v1.39.3\nconst x = 1;",
|
||||
filename: "/repo/src/lib/insights/graded-series.ts",
|
||||
},
|
||||
{
|
||||
// Pending: owned by another change, switched at merge.
|
||||
code: 'const DEFAULT_TZ = "Europe/Berlin";',
|
||||
filename: "/repo/src/lib/medications/window-status.ts",
|
||||
},
|
||||
{
|
||||
code: 'const other = "America/New_York";',
|
||||
filename: "/repo/src/lib/insights/graded-series.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const tz = user.timezone || "Europe/Berlin";',
|
||||
filename: "/repo/src/app/api/insights/cards/route.ts",
|
||||
errors: [{ messageId: "zoneLiteral" }],
|
||||
},
|
||||
{
|
||||
code: 'readDayAggregates({ timeZone: "Europe/Berlin" });',
|
||||
filename: "/repo/src/lib/insights/graded-series.ts",
|
||||
errors: [{ messageId: "zoneLiteral" }],
|
||||
},
|
||||
{
|
||||
code: "const label = `Date: ${todayKey} (Europe/Berlin)`;",
|
||||
filename: "/repo/src/lib/ai/prompts/pulse.ts",
|
||||
errors: [{ messageId: "zoneLiteral" }],
|
||||
},
|
||||
{
|
||||
code: '<input placeholder="Europe/Berlin" />',
|
||||
filename: "/repo/src/components/settings/timezone-picker.tsx",
|
||||
errors: [{ messageId: "zoneLiteral" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -104,6 +104,10 @@ const eslintConfig = defineConfig([
|
||||
// catches the omission only — a handler that returns `{ ok: true }`
|
||||
// after swallowing still passes, and its header says so.
|
||||
"healthlog/job-handler-outcome": "error",
|
||||
// The default zone is named once, in src/lib/tz/format.ts. A copy of the
|
||||
// literal anywhere else is where a user outside that zone got someone
|
||||
// else's calendar. src/lib/tz/ and test files are exempt; see the rule.
|
||||
"healthlog/no-default-zone-literal": "error",
|
||||
},
|
||||
},
|
||||
// v1.28.17 — every recharts-rendering component funnels through the
|
||||
|
||||
@@ -259,18 +259,10 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
|
||||
file: "src/components/measurement-reminders/vorsorge-section.tsx",
|
||||
why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/gamification/achievements.ts",
|
||||
why: "The day key streak arithmetic counts on.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/charts/bucket-time-series.ts",
|
||||
why: "Bucket boundary arithmetic. The bucket LABELS are a separate step and go through `makeBucketLabelFormatters`.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/ai/prompts/insight-generator.ts",
|
||||
why: "An ISO day inside a model prompt. The model reads ISO; the user never sees this string.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/jobs/measurement-reminder.ts",
|
||||
why: "The local day in the notification dedup key. Changing it with a display preference would re-send a claimed reminder.",
|
||||
@@ -283,10 +275,6 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
|
||||
file: "src/lib/i18n/relative-time.ts",
|
||||
why: "The en-CA day keys that decide today / yesterday. A comparison between two keys, never printed.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/tz/resolver.ts",
|
||||
why: "The Berlin day formatter the zone arithmetic itself is built on.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Every place that writes `Measurement` rows either runs the shared
|
||||
* post-write tail or says here why it does not.
|
||||
*
|
||||
* Two readers hang off every measurement write: the rollup buckets the write
|
||||
* dirtied, and the cached status assessments of the types it touched. The
|
||||
* shared tail (`afterMeasurementMutation`) runs both. Several write paths
|
||||
* refreshed the rollups by hand and never re-warmed the assessments (the MCP
|
||||
* writes, the Telegram reply, both importers), so a reading logged there kept
|
||||
* the day's old assessment until the nightly pass. This guard freezes the set
|
||||
* of write sites: a new one fails until it is listed with its tail.
|
||||
*
|
||||
* A tripwire, not a proof: it cannot tell whether a listed reason is still
|
||||
* true, only that the set did not change without someone editing this file.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* `prisma.measurement.create(`, `tx.measurement.deleteMany(`, … Whitespace
|
||||
* and line breaks between the parts are tolerated so a formatter reflow does
|
||||
* not hide a site.
|
||||
*/
|
||||
const WRITE_RE =
|
||||
/\.\s*measurement\s*\.\s*(?:create|createMany|createManyAndReturn|upsert|update|updateMany|delete|deleteMany)\s*\(/g;
|
||||
|
||||
/** Runs `afterMeasurementMutation` on the rows it wrote. */
|
||||
const TAIL = "runs afterMeasurementMutation";
|
||||
|
||||
/**
|
||||
* Every file with a measurement write, and how it keeps the two readers
|
||||
* current. A file listed here must still contain a write (a stale entry
|
||||
* fails too).
|
||||
*/
|
||||
const WRITE_SITES: Record<string, string> = {
|
||||
"app/api/import/csv/route.ts": TAIL,
|
||||
"app/api/import/route.ts": TAIL,
|
||||
"app/api/measurements/[id]/route.ts": TAIL,
|
||||
"app/api/measurements/bulk-delete/route.ts": TAIL,
|
||||
"app/api/measurements/by-external-ids/route.ts": TAIL,
|
||||
"app/api/measurements/restore/route.ts": TAIL,
|
||||
"app/api/measurements/route.ts": TAIL,
|
||||
"app/api/mental-health/assessments/route.ts": TAIL,
|
||||
"lib/insights/score-row.ts": TAIL,
|
||||
"lib/mcp/writes.ts": TAIL,
|
||||
"lib/measurements/create-from-telegram.ts": TAIL,
|
||||
"lib/whoop/sync-body.ts": TAIL,
|
||||
"lib/whoop/webhook-handler.ts": TAIL,
|
||||
// Integration syncs: each collects the (type, day) pairs it touched and
|
||||
// runs both legs itself at the end of the sync.
|
||||
"lib/fitbit/sync-core.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the upsert",
|
||||
"lib/google-health/sync-core.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the upsert",
|
||||
"lib/nightscout/sync.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the sync",
|
||||
"lib/withings/sync.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the sync",
|
||||
"lib/withings/sync-activity.ts":
|
||||
"refolds the touched days (and the day a moved row left) and re-warms the touched types",
|
||||
"lib/withings/sync-ecg.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the sync",
|
||||
// The reconcile primitive writes one row inside the caller's transaction;
|
||||
// its callers (Polar, Oura, WHOOP, the Apple export import) collect the
|
||||
// verdicts' dirty identities and run the tail once per batch.
|
||||
"lib/measurements/reconcile-external-measurement.ts":
|
||||
"a primitive; every caller runs the tail over the verdicts' dirty identities",
|
||||
"lib/measurements/import-apple-health-export.ts":
|
||||
"the import worker refolds the import's span and re-warms every imported type",
|
||||
"lib/export/restore-backup.ts":
|
||||
"a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
|
||||
// Value-preserving reshapes: the same readings folded into daily or hourly
|
||||
// rows. The rollups are refolded; the figures an assessment reads do not
|
||||
// change, so re-warming would only re-bill the same text.
|
||||
"lib/measurements/consolidate-daily-mean.ts":
|
||||
"value-preserving consolidation; refolds the touched days",
|
||||
"lib/measurements/consolidate-legacy-steps.ts":
|
||||
"value-preserving consolidation; refolds the touched days",
|
||||
"lib/measurements/drain-per-sample-cumulative.ts":
|
||||
"value-preserving consolidation; refolds the touched days",
|
||||
"lib/measurements/dense-intraday-hourly-rebuild.ts":
|
||||
"value-preserving hourly fold; refolds the touched spans",
|
||||
"lib/measurements/dense-intraday-retention.ts":
|
||||
"value-preserving retention fold; refolds the touched days",
|
||||
"lib/jobs/sleep-timeline-backfill.ts":
|
||||
"one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days",
|
||||
"lib/jobs/step-consolidation-repair.ts":
|
||||
"one-shot restore of step rows an old consolidation removed; refolds the touched days",
|
||||
"lib/sleep/sweep-stale-segments.ts":
|
||||
"tombstones re-scored sleep segments inside a sync; the calling sync refolds the night",
|
||||
// Writes that do not change a reading.
|
||||
"lib/jobs/note-encryption-backfill.ts":
|
||||
"encrypts the note column in place; no value, instant or type changes",
|
||||
"lib/jobs/measurement-tombstone-cleanup.ts":
|
||||
"hard-deletes rows that were already soft-deleted and read nowhere",
|
||||
};
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function writeCounts(): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
for (const rel of sourceFiles()) {
|
||||
const matches = readFileSync(join(SRC, rel), "utf8").match(WRITE_RE);
|
||||
if (matches && matches.length > 0) out.set(rel, matches.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("measurement write tail", () => {
|
||||
const counts = writeCounts();
|
||||
|
||||
it("finds the write sites at all", () => {
|
||||
// An empty match set would agree with any allowlist.
|
||||
expect(counts.size).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("lists every file that writes measurements", () => {
|
||||
const unlisted = [...counts.keys()].filter((f) => !(f in WRITE_SITES));
|
||||
expect(
|
||||
unlisted,
|
||||
"a new measurement write site: run afterMeasurementMutation on the rows it writes, or list it here with the reason it does not",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no stale entries", () => {
|
||||
const stale = Object.keys(WRITE_SITES).filter((f) => !counts.has(f));
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the files that claim the shared tail call it", () => {
|
||||
const missing = Object.entries(WRITE_SITES)
|
||||
.filter(([, reason]) => reason === TAIL)
|
||||
.map(([file]) => file)
|
||||
.filter(
|
||||
(file) =>
|
||||
!/afterMeasurementMutation\s*\(/.test(
|
||||
readFileSync(join(SRC, file), "utf8"),
|
||||
),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("the syncs that run both legs by hand call both", () => {
|
||||
const handRolled = Object.entries(WRITE_SITES)
|
||||
.filter(([, reason]) => reason.includes("re-warms"))
|
||||
.map(([file]) => file)
|
||||
.filter(
|
||||
(file) => file !== "lib/measurements/import-apple-health-export.ts",
|
||||
);
|
||||
expect(handRolled.length).toBeGreaterThan(0);
|
||||
const missing = handRolled.filter((file) => {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
return !(
|
||||
/recomputeBucketsForMeasurement|afterMeasurementMutation/.test(src) &&
|
||||
/invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src)
|
||||
);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -116,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let notificationsSent = 0;
|
||||
|
||||
for (const med of medications) {
|
||||
const userTz = med.user.timezone || "Europe/Berlin";
|
||||
const userTz = med.user.timezone || DEFAULT_TIMEZONE;
|
||||
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
|
||||
now,
|
||||
userTz,
|
||||
|
||||
@@ -44,8 +44,7 @@ import {
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
async function postAction(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -67,6 +67,7 @@ import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { validTimezoneOr } from "@/lib/tz/format";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import { computeStreak } from "@/lib/dashboard/streak";
|
||||
import {
|
||||
readLatestEver,
|
||||
readSparkBuckets,
|
||||
@@ -256,53 +257,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
}
|
||||
|
||||
/** Compute the current logging-day streak (days where any measurement or
|
||||
* intake event was recorded, in the user's display timezone) plus the
|
||||
* longest streak in the last `STREAK_WINDOW_DAYS` days.
|
||||
*
|
||||
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
|
||||
* Auckland user gets their Auckland-day streak rather than the Berlin
|
||||
* one. The activity-day Set entries are already produced in the same
|
||||
* zone by the caller (via `userDayKey`), so the cursor walk only needs
|
||||
* the same zone here to align. */
|
||||
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
|
||||
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
|
||||
|
||||
const sorted = [...activityDays].sort();
|
||||
let longest = 1;
|
||||
let run = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
|
||||
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
|
||||
if (cur - prev === 86_400_000) {
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Current streak: walk back from today (user's tz).
|
||||
const todayKey = userDayKey(new Date(), userTz);
|
||||
let currentDays = 0;
|
||||
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
|
||||
// Allow yesterday's last day to count if today not yet logged.
|
||||
if (!activityDays.has(userDayKey(cursor, userTz))) {
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
while (activityDays.has(userDayKey(cursor, userTz))) {
|
||||
currentDays += 1;
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
|
||||
* The route only needs YYYY-MM-DD strings, so the aggregate runs
|
||||
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
|
||||
|
||||
@@ -22,7 +22,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
|
||||
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "@/lib/validations/illness";
|
||||
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
|
||||
import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
parseCsvMeasurements,
|
||||
type CsvRowResult,
|
||||
} from "@/lib/import/csv-measurements";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
@@ -317,24 +314,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
|
||||
// Best-effort: a populator hiccup never fails the importer.
|
||||
if (touchedMeasurements.length > 0) {
|
||||
try {
|
||||
const keys = collapseToTypeDayKeys(touchedMeasurements);
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
measurement_rollup_csv_import_failed: true,
|
||||
measurement_rollup_csv_import_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks — and
|
||||
// the status re-warm for the touched types. Best-effort: a populator
|
||||
// hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
|
||||
|
||||
await auditLog("import.csv.upload", {
|
||||
userId,
|
||||
|
||||
@@ -32,15 +32,13 @@ import {
|
||||
} from "@/lib/validations/external-id";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
@@ -481,27 +479,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.39.1 — refresh the persistent measurement rollup table for
|
||||
// every distinct (type, day) the import touched. Collapsed so a
|
||||
// 10 000-row CSV restore pays at most ~N (type, day) recomputes
|
||||
// rather than 10 000 per-row hooks. Best-effort: a populator hiccup
|
||||
// never fails the importer.
|
||||
if (touchedMeasurements.length > 0) {
|
||||
try {
|
||||
const keys = collapseToTypeDayKeys(touchedMeasurements);
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
measurement_rollup_import_failed: true,
|
||||
measurement_rollup_import_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Refresh the persistent measurement rollup table for every distinct
|
||||
// (type, day) the import touched (collapsed, so a 10 000-row restore pays
|
||||
// at most ~N recomputes) and re-warm the touched types' status
|
||||
// assessments. Best-effort: a populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, "import");
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,8 +34,8 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
import {
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
localDayWindow,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
@@ -76,6 +76,7 @@ import type {
|
||||
MeasurementSource,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
@@ -192,7 +193,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const tz =
|
||||
user.timezone && user.timezone.length > 0
|
||||
? user.timezone
|
||||
: "Europe/Berlin";
|
||||
: DEFAULT_TIMEZONE;
|
||||
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
|
||||
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
|
||||
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
|
||||
@@ -242,7 +243,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const tz =
|
||||
user.timezone && user.timezone.length > 0
|
||||
? user.timezone
|
||||
: "Europe/Berlin";
|
||||
: DEFAULT_TIMEZONE;
|
||||
// v1.29.6 — DESC + take so a long history returns the MOST RECENT
|
||||
// window of raw samples for bucketing. The previous `orderBy: asc`
|
||||
// + `take: limit` sliced the OLDEST page of rows before the
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -43,7 +44,7 @@ export const GET = apiHandler(
|
||||
return apiError("Too many compliance requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Read-through the per-user compliance cache (15 min fresh TTL +
|
||||
// SWR window). The ownership guard and the medication read live
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -120,7 +121,7 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -31,7 +32,7 @@ export const GET = apiHandler(
|
||||
return apiError("Too many efficacy requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
const dto = await buildMedicationEfficacy(user.id, id, userTz);
|
||||
if (!dto) {
|
||||
return apiError("Medication not found", 404);
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { floorWhole } from "@/lib/medications/units-per-dose";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -176,7 +177,7 @@ export const GET = apiHandler(
|
||||
},
|
||||
schedules: liveSchedules,
|
||||
now,
|
||||
userTz: user.timezone || "Europe/Berlin",
|
||||
userTz: user.timezone || DEFAULT_TIMEZONE,
|
||||
lastIntakeAt: lastIntake?.takenAt ?? null,
|
||||
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
|
||||
eraStart: latestRevision?.validUntil ?? null,
|
||||
@@ -604,7 +605,7 @@ export const PUT = apiHandler(
|
||||
// `syncVersion` so delta-sync clients drop them. The projector and
|
||||
// the reminder worker re-mint the anchors for the new times on their
|
||||
// next pass — the route does not pre-create them.
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
|
||||
const tombstoneWhere = {
|
||||
userId: user.id,
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { heatmapDays } from "@/lib/charts/heatmap-days";
|
||||
|
||||
interface DailyData {
|
||||
expected: number;
|
||||
@@ -79,6 +84,7 @@ export function ComplianceHeatmap({
|
||||
}: ComplianceHeatmapProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const timeZone = useDisplayTimezone();
|
||||
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
|
||||
// locale-aware renderer never lands a day off in either direction.
|
||||
const formatDay = (dateKey: string) =>
|
||||
@@ -175,7 +181,6 @@ export function ComplianceHeatmap({
|
||||
t("charts.months.nov"),
|
||||
t("charts.months.dec"),
|
||||
];
|
||||
const now = new Date();
|
||||
const cellList: Array<{
|
||||
dateKey: string;
|
||||
col: number;
|
||||
@@ -184,34 +189,18 @@ export function ComplianceHeatmap({
|
||||
data: DailyData;
|
||||
}> = [];
|
||||
|
||||
// Build array of dates from oldest to newest
|
||||
const dates: Date[] = [];
|
||||
for (let d = days - 1; d >= 0; d--) {
|
||||
const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
|
||||
dates.push(date);
|
||||
}
|
||||
|
||||
// Start from the first date, align to Monday.
|
||||
//
|
||||
// v1.4.27 B7 / BL-P4-2 — read every weekday + month boundary off
|
||||
// the UTC accessor pair (`getUTCDay`, `getUTCMonth`) so the
|
||||
// computation matches the dateKey, which is also UTC-anchored
|
||||
// (`toISOString().slice(0, 10)`). Reading in server-tz here would
|
||||
// shift the Monday-alignment + month-marker placement when the
|
||||
// SSR pass runs on a server in a non-Berlin timezone.
|
||||
const firstDate = dates[0];
|
||||
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
|
||||
// Oldest to newest, one entry per local day in the user's zone.
|
||||
const dates = heatmapDays(new Date(), timeZone, days);
|
||||
const firstDow = dates[0]?.dow ?? 0;
|
||||
|
||||
let col = 0;
|
||||
const markers: Array<{ col: number; label: string }> = [];
|
||||
let lastMonth = -1;
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const date = dates[i];
|
||||
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
|
||||
const { dateKey, dow, month } = dates[i];
|
||||
const currentCol = Math.floor((i + firstDow) / 7);
|
||||
const row = dow;
|
||||
const dateKey = date.toISOString().slice(0, 10);
|
||||
const data = dailyCompliance[dateKey] ?? {
|
||||
expected: 0,
|
||||
taken: 0,
|
||||
@@ -219,7 +208,6 @@ export function ComplianceHeatmap({
|
||||
};
|
||||
|
||||
// Track month boundaries
|
||||
const month = date.getUTCMonth();
|
||||
if (month !== lastMonth) {
|
||||
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
|
||||
lastMonth = month;
|
||||
@@ -237,7 +225,7 @@ export function ComplianceHeatmap({
|
||||
}
|
||||
|
||||
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
|
||||
}, [dailyCompliance, days, t]);
|
||||
}, [dailyCompliance, days, t, timeZone]);
|
||||
|
||||
const activeDays = cells.filter((cell) => cell.data.expected > 0).length;
|
||||
const summaryLabel =
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useViewportWidth } from "@/hooks/use-viewport-width";
|
||||
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
interface DailyCompliancePoint {
|
||||
/** Berlin calendar day, "YYYY-MM-DD". */
|
||||
@@ -208,7 +209,7 @@ interface MedicationComplianceChartProps {
|
||||
|
||||
export function MedicationComplianceChart({
|
||||
title,
|
||||
userTimezone = "Europe/Berlin",
|
||||
userTimezone = DEFAULT_TIMEZONE,
|
||||
compareBaseline,
|
||||
onDataReady,
|
||||
}: MedicationComplianceChartProps) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { heatmapDays } from "@/lib/charts/heatmap-days";
|
||||
import { moodLabelKeyForScore } from "@/lib/mood/labels";
|
||||
|
||||
/**
|
||||
@@ -62,6 +67,7 @@ export function MoodHeatmap({
|
||||
}: MoodHeatmapProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const timeZone = useDisplayTimezone();
|
||||
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
|
||||
// locale-aware renderer never lands a day off in either direction.
|
||||
const formatDay = (dateKey: string) =>
|
||||
@@ -147,7 +153,6 @@ export function MoodHeatmap({
|
||||
t("charts.months.nov"),
|
||||
t("charts.months.dec"),
|
||||
];
|
||||
const now = new Date();
|
||||
const cellList: Array<{
|
||||
dateKey: string;
|
||||
col: number;
|
||||
@@ -156,30 +161,19 @@ export function MoodHeatmap({
|
||||
cell: MoodHeatmapCell | null;
|
||||
}> = [];
|
||||
|
||||
const dates: Date[] = [];
|
||||
for (let d = days - 1; d >= 0; d--) {
|
||||
const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
|
||||
dates.push(date);
|
||||
}
|
||||
|
||||
// Monday-align + month markers read off the UTC accessors so the
|
||||
// grid matches the UTC-anchored dateKey (mirrors compliance-heatmap).
|
||||
const firstDate = dates[0];
|
||||
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
|
||||
const dates = heatmapDays(new Date(), timeZone, days);
|
||||
const firstDow = dates[0]?.dow ?? 0;
|
||||
|
||||
let col = 0;
|
||||
const markers: Array<{ col: number; label: string }> = [];
|
||||
let lastMonth = -1;
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const date = dates[i];
|
||||
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
|
||||
const { dateKey, dow, month } = dates[i];
|
||||
const currentCol = Math.floor((i + firstDow) / 7);
|
||||
const row = dow;
|
||||
const dateKey = date.toISOString().slice(0, 10);
|
||||
const cell = cellData[dateKey] ?? null;
|
||||
|
||||
const month = date.getUTCMonth();
|
||||
if (month !== lastMonth) {
|
||||
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
|
||||
lastMonth = month;
|
||||
@@ -197,7 +191,7 @@ export function MoodHeatmap({
|
||||
}
|
||||
|
||||
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
|
||||
}, [cellData, days, t]);
|
||||
}, [cellData, days, t, timeZone]);
|
||||
|
||||
const loggedDays = cells.filter((cell) => cell.cell !== null).length;
|
||||
const summaryLabel =
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
intakeToastOptions,
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
|
||||
@@ -115,7 +116,7 @@ export function MedicationIntakeQuickAdd({
|
||||
// source the cards use); Berlin stays the last-resort fallback so
|
||||
// logged-out mounts behave unchanged.
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
// The record this dose lands in, or null in the caller's own.
|
||||
const recordName = useActiveRecordName();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
|
||||
import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
@@ -94,7 +94,7 @@ export function IntradayPulseChart({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const tz = userTimezone ?? "Europe/Berlin";
|
||||
const tz = userTimezone ?? DEFAULT_TIMEZONE;
|
||||
const todayKey = userDayKey(new Date(), tz);
|
||||
// `null` tracks "today" live (so a session left open across midnight keeps
|
||||
// following the current day); a non-null value pins the viewed day once
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
|
||||
}));
|
||||
|
||||
import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section";
|
||||
import { DISPLAY_TIMEZONE } from "@/lib/format-locale";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Noon on the day `offsetDays` from today, counted on the DISPLAY zone's
|
||||
@@ -53,7 +53,7 @@ import { DISPLAY_TIMEZONE } from "@/lib/format-locale";
|
||||
function noonOnDisplayDay(offsetDays: number): Date {
|
||||
// `en-CA` formats as YYYY-MM-DD, which is the shape we want to add days to.
|
||||
const today = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: DISPLAY_TIMEZONE,
|
||||
timeZone: DEFAULT_TIMEZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -219,7 +219,7 @@ describe("<VorsorgeSection> loading + empty", () => {
|
||||
// the wall-clock gap to now exceeds the 24h a rolling delta would key on.
|
||||
// Anchor the due instant to noon on the DISPLAY zone's today, not the
|
||||
// process clock's. `vitest.config.mts` pins the suite to TZ=UTC while the
|
||||
// component floors in the profile zone (`DISPLAY_TIMEZONE`, Europe/Berlin),
|
||||
// component floors in the profile zone (`DEFAULT_TIMEZONE`, Europe/Berlin),
|
||||
// and those two disagree about the date for the two hours before UTC
|
||||
// midnight. Anchoring on `setHours` made this case fail every night in
|
||||
// that window, which is the same confusion of clocks the assertion exists
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
/**
|
||||
* v1.12.3 — resolve the canonical slot instant of the dose a medication
|
||||
* card is currently surfacing, so the card's "Genommen" / "Skip" buttons
|
||||
@@ -138,7 +139,7 @@ export function resolveDisplayedSlotInstant(
|
||||
currentWindowStatus,
|
||||
nextDueAt,
|
||||
now,
|
||||
timeZone = "Europe/Berlin",
|
||||
timeZone = DEFAULT_TIMEZONE,
|
||||
} = input;
|
||||
|
||||
// The card surfaces the current/overdue dose band — record that band's
|
||||
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
type LedgerRow,
|
||||
} from "@/components/medications/dose-history-ledger-compute";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -182,7 +183,7 @@ export function DoseHistoryLedger({
|
||||
// marked — the most-encountered of the set.
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const timeZone = user?.timezone || "Europe/Berlin";
|
||||
const timeZone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<{
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication card variant.
|
||||
@@ -202,7 +203,7 @@ export function Glp1MedicationCard({
|
||||
// v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the
|
||||
// last-resort fallback for logged-out mounts and legacy fixtures.
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const fmt = useFormatters();
|
||||
// Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The
|
||||
// weekday name paired with `fmt.dateShort` must be derived in this SAME
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
runLogIntake,
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
interface LogIntakeSchedule extends ScheduleWindowInput {
|
||||
label: string | null;
|
||||
@@ -120,7 +121,7 @@ export function LogIntakeDialog({
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const formId = useId();
|
||||
|
||||
const [medicationOverride, setMedicationOverride] = useState<string | null>(
|
||||
|
||||
@@ -34,6 +34,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -149,7 +150,7 @@ export function MedicationCard({
|
||||
// session carries); Berlin stays the last-resort fallback so logged-out
|
||||
// mounts and legacy fixtures behave unchanged.
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const fmt = useFormatters();
|
||||
const weekdayLabel = useWeekdayLabel();
|
||||
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.16.10 — the compact table view for /medications (issue #316 item 3).
|
||||
@@ -194,7 +195,7 @@ export function MedicationTable({
|
||||
}: MedicationTableProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const [sort, setSort] = useState<MedicationTableSort | null>(initialSort);
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -206,6 +206,8 @@ export async function buildCoarseTimelineTail(
|
||||
type: MeasurementType,
|
||||
now: Date,
|
||||
tz: string,
|
||||
/** The user's source-priority blob, already loaded by the caller. */
|
||||
userPriorityJson: unknown,
|
||||
): Promise<CoarseTimelineTail | undefined> {
|
||||
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
|
||||
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
|
||||
@@ -222,6 +224,7 @@ export async function buildCoarseTimelineTail(
|
||||
now: now.getTime(),
|
||||
tz,
|
||||
coarseOnly: true,
|
||||
userPriorityJson,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
|
||||
@@ -548,6 +548,7 @@ async function buildCoachSnapshotImpl(
|
||||
"BLOOD_PRESSURE_SYS" as MeasurementType,
|
||||
now,
|
||||
userTz,
|
||||
prefsRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
}
|
||||
if (wantsWeight) {
|
||||
@@ -556,6 +557,7 @@ async function buildCoachSnapshotImpl(
|
||||
"WEIGHT" as MeasurementType,
|
||||
now,
|
||||
userTz,
|
||||
prefsRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
}
|
||||
if (wantsPulse) {
|
||||
@@ -564,6 +566,7 @@ async function buildCoachSnapshotImpl(
|
||||
"PULSE" as MeasurementType,
|
||||
now,
|
||||
userTz,
|
||||
prefsRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
}
|
||||
const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
|
||||
|
||||
@@ -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,10 +29,9 @@ import {
|
||||
resolveRestingPulseSeries,
|
||||
type PulseSample,
|
||||
} from "@/lib/analytics/resting-pulse";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
|
||||
/** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */
|
||||
function median(values: number[]): number | null {
|
||||
@@ -96,7 +95,7 @@ describe("loadIntradayPulse", () => {
|
||||
// Sanity: under the OLD hardcoded-Berlin bug all six samples fall on the
|
||||
// SAME Berlin day — proving the two tz groupings genuinely diverge.
|
||||
const berlinDays = new Set(
|
||||
[...dayA, ...dayB].map((d) => toBerlinDayKey(d)),
|
||||
[...dayA, ...dayB].map((d) => userDayKey(d, "Europe/Berlin")),
|
||||
);
|
||||
expect(berlinDays.size).toBe(1);
|
||||
|
||||
@@ -151,7 +150,7 @@ describe("loadIntradayPulse", () => {
|
||||
const buggyBerlin = resolveRestingPulseSeries({
|
||||
restingSamples: [],
|
||||
pulseSamples: pulseHistory as PulseSample[],
|
||||
dayKeyOf: (d) => toBerlinDayKey(d),
|
||||
dayKeyOf: (d) => userDayKey(d, "Europe/Berlin"),
|
||||
});
|
||||
const buggyBaseline = median(
|
||||
buggyBerlin.series.slice(-30).map((p) => p.value),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readRollupBuckets: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: mocks.findMany } },
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
readRollupBuckets: mocks.readRollupBuckets,
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
aggregateWmyBuckets: vi.fn(),
|
||||
readBestGranularityRollups: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
|
||||
import { computeRangeDelta } from "@/lib/analytics/range-delta";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const NOW = Date.UTC(2026, 5, 30, 12);
|
||||
|
||||
function dayRow(daysAgo: number, total: number, samples: number) {
|
||||
const bucketStart = new Date(Date.UTC(2026, 5, 30) - daysAgo * DAY_MS);
|
||||
return {
|
||||
bucketStart,
|
||||
count: samples,
|
||||
mean: total / samples,
|
||||
minValue: 0,
|
||||
maxValue: total,
|
||||
sd: null,
|
||||
slope: null,
|
||||
r2: null,
|
||||
computedAt: bucketStart,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.readRollupBuckets.mockReset();
|
||||
mocks.findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("computeRangeDelta — per-day and per-night metrics", () => {
|
||||
it("compares average daily step totals, not the mean of the readings", async () => {
|
||||
// Six complete days of 8,000 in the previous week, and in the current
|
||||
// week five drained days plus today still arriving as 200 samples.
|
||||
mocks.readRollupBuckets.mockResolvedValue([
|
||||
...[8, 9, 10, 11, 12, 13].map((d) => dayRow(d, 8000, 1)),
|
||||
...[1, 2, 3, 4, 5].map((d) => dayRow(d, 8000, 1)),
|
||||
dayRow(0, 4000, 200),
|
||||
]);
|
||||
|
||||
const result = await computeRangeDelta("u", "ACTIVITY_STEPS", "7d", NOW);
|
||||
|
||||
expect(result.granularity).toBe("DAY");
|
||||
expect(result.previous).toMatchObject({ count: 6, mean: 8000 });
|
||||
expect(result.current.count).toBe(6);
|
||||
expect(result.current.mean).toBeCloseTo((5 * 8000 + 4000) / 6, 6);
|
||||
expect(result.current.sum).toBeCloseTo(44_000, 6);
|
||||
});
|
||||
|
||||
it("compares average nights of sleep, not the mean of stage rows", async () => {
|
||||
const row = (iso: string, minutes: number, sleepStage: string) => ({
|
||||
value: minutes,
|
||||
measuredAt: new Date(iso),
|
||||
sleepStage,
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
});
|
||||
// Two nights: 7 h asleep (split into three stage rows plus an in-bed
|
||||
// envelope) and 6 h asleep.
|
||||
mocks.findMany.mockResolvedValue([
|
||||
row("2026-06-28T01:00:00.000Z", 480, "IN_BED"),
|
||||
row("2026-06-28T02:00:00.000Z", 180, "CORE"),
|
||||
row("2026-06-28T04:00:00.000Z", 120, "DEEP"),
|
||||
row("2026-06-28T06:00:00.000Z", 120, "REM"),
|
||||
row("2026-06-29T06:00:00.000Z", 360, "ASLEEP"),
|
||||
]);
|
||||
|
||||
const result = await computeRangeDelta("u", "SLEEP_DURATION", "7d", NOW);
|
||||
|
||||
expect(result.granularity).toBe("live");
|
||||
expect(result.current.count).toBe(2);
|
||||
expect(result.current.mean).toBeCloseTo(390, 6);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
type PulseSample,
|
||||
} from "../resting-pulse";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const DAY_KEY = (d: Date) => userDayKey(d, "Europe/Berlin");
|
||||
|
||||
/**
|
||||
* The zone `deriveRestingProxyFromPulse` buckets in by default
|
||||
@@ -69,7 +72,7 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
),
|
||||
);
|
||||
}
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
|
||||
expect(proxy).toHaveLength(1);
|
||||
// 510 samples, only 10 below ~80 — but the 20th percentile of the
|
||||
// whole day still lands in the workout band because 98 % of samples
|
||||
@@ -94,7 +97,7 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
samples.push(day(`2026-06-02T18:${String(i).padStart(2, "0")}:00`, 150));
|
||||
}
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
|
||||
expect(proxy).toHaveLength(1);
|
||||
// 20th percentile sits in the low band, well below the workout reads.
|
||||
expect(proxy[0].value).toBeLessThanOrEqual(80);
|
||||
@@ -112,7 +115,7 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
day("2026-06-01T12:00:00", 64),
|
||||
day("2026-06-01T20:00:00", 68),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
|
||||
expect(proxy).toHaveLength(2);
|
||||
expect(proxy[0].measuredAt.getTime()).toBeLessThan(
|
||||
proxy[1].measuredAt.getTime(),
|
||||
@@ -125,7 +128,9 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
const oneSampleWorkoutDay: PulseSample[] = [
|
||||
day("2026-06-05T18:00:00", 165),
|
||||
];
|
||||
expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay)).toEqual([]);
|
||||
expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay, DAY_KEY)).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
// A day below the min-sample floor is dropped; only the day with enough
|
||||
// samples contributes.
|
||||
@@ -135,13 +140,13 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
day("2026-06-06T08:00:00", 62),
|
||||
day("2026-06-06T09:00:00", 64),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(mixed);
|
||||
const proxy = deriveRestingProxyFromPulse(mixed, DAY_KEY);
|
||||
expect(proxy).toHaveLength(1);
|
||||
expect(proxy[0].value).toBeLessThan(150);
|
||||
});
|
||||
|
||||
it("returns empty for no samples", () => {
|
||||
expect(deriveRestingProxyFromPulse([])).toEqual([]);
|
||||
expect(deriveRestingProxyFromPulse([], DAY_KEY)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +163,7 @@ describe("resolveRestingPulseSeries", () => {
|
||||
day("2026-06-01T18:00:00", 150),
|
||||
);
|
||||
const { series, which } = resolveRestingPulseSeries({
|
||||
dayKeyOf: DAY_KEY,
|
||||
restingSamples,
|
||||
pulseSamples,
|
||||
});
|
||||
@@ -174,6 +180,7 @@ describe("resolveRestingPulseSeries", () => {
|
||||
day("2026-06-01T18:00:00", 150),
|
||||
];
|
||||
const { series, which } = resolveRestingPulseSeries({
|
||||
dayKeyOf: DAY_KEY,
|
||||
restingSamples: [],
|
||||
pulseSamples,
|
||||
});
|
||||
@@ -184,7 +191,11 @@ describe("resolveRestingPulseSeries", () => {
|
||||
|
||||
it("reports 'none' when neither series has data", () => {
|
||||
expect(
|
||||
resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }),
|
||||
resolveRestingPulseSeries({
|
||||
dayKeyOf: DAY_KEY,
|
||||
restingSamples: [],
|
||||
pulseSamples: [],
|
||||
}),
|
||||
).toEqual({ series: [], which: "none" });
|
||||
});
|
||||
|
||||
@@ -218,6 +229,7 @@ describe("resolveRestingPulseSeries", () => {
|
||||
}
|
||||
|
||||
const { series, which } = resolveRestingPulseSeries({
|
||||
dayKeyOf: DAY_KEY,
|
||||
restingSamples,
|
||||
pulseSamples,
|
||||
});
|
||||
@@ -249,6 +261,7 @@ describe("resolveRestingPulseSeries", () => {
|
||||
// A native day must not be second-guessed by the proxy, and a native
|
||||
// account with no gap days keeps the honest 'resting' label.
|
||||
const { series, which } = resolveRestingPulseSeries({
|
||||
dayKeyOf: DAY_KEY,
|
||||
restingSamples: [day("2026-06-01T04:00:00", 58)],
|
||||
pulseSamples: [
|
||||
day("2026-06-01T07:00:00", 80),
|
||||
@@ -276,7 +289,7 @@ describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", ()
|
||||
day("2026-06-02T15:00:00", 68),
|
||||
day("2026-06-02T21:00:00", 70),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
|
||||
expect(proxy).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -293,7 +306,7 @@ describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", ()
|
||||
day("2026-06-02T00:30:00", 82),
|
||||
day("2026-06-02T00:50:00", 84),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
const proxy = deriveRestingProxyFromPulse(samples, DAY_KEY);
|
||||
expect(proxy).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -412,16 +412,9 @@ describe("computeSummariesSlice", () => {
|
||||
// call is the pre-fold all-time remainder splice.
|
||||
expect(RAW).toHaveBeenCalledTimes(2);
|
||||
expect(UNSAFE).toHaveBeenCalledTimes(3);
|
||||
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
|
||||
// `readBestGranularityRollups(userId, type, 395)` per
|
||||
// type-with-data via `prisma.measurementRollup.findMany`. The
|
||||
// 395-day window skips the YEAR floor (731 d) and walks MONTH
|
||||
// → WEEK → DAY on full coverage miss; the default mock returns
|
||||
// `[]` so all three reachable tiers probe.
|
||||
//
|
||||
// v1.20.0 F6 — plus the 90-day accumulator findMany the warm path
|
||||
// now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4.
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
|
||||
// The 90-day accumulator findMany plus one year-ago DAY read per
|
||||
// type-with-data.
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Watched red: with the pre-fold splice removed from the rollup path
|
||||
@@ -483,28 +476,14 @@ describe("computeSummariesSlice", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
|
||||
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
|
||||
* `null`; we now populate it from `readBestGranularityRollups` when
|
||||
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
|
||||
* the `[now-395d, now-365d)` slice.
|
||||
* `avg30LastYear` is the mean over the exact 30 UTC days that start 395
|
||||
* days ago, read from canonical DAY buckets.
|
||||
*/
|
||||
describe("year-over-year wiring (avg30LastYear)", () => {
|
||||
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
|
||||
// Coverage probe shows WEIGHT covered → rollup happy path.
|
||||
function warmPathMocks() {
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]);
|
||||
UNSAFE.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
avg7: 82,
|
||||
avg30: 82.5,
|
||||
slope7: 0,
|
||||
r2_7: 0,
|
||||
slope30: 0,
|
||||
r2_30: 0,
|
||||
slope90: 0,
|
||||
r2_90: 0,
|
||||
},
|
||||
{ type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
|
||||
@@ -512,120 +491,59 @@ describe("computeSummariesSlice", () => {
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
|
||||
]);
|
||||
|
||||
// v1.20.0 F6 — the warm path runs the 90-day accumulator findMany
|
||||
// first; return empty (slope composes to null, irrelevant to this
|
||||
// year-over-year assertion) so the year-ago MONTH probe is call #2.
|
||||
// The 90-day accumulator findMany runs first; empty (slope composes
|
||||
// to null, irrelevant here) so the year-ago DAY read is call #1.
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
|
||||
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
|
||||
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
|
||||
// (count=10, mean=85) → weighted mean = 85.
|
||||
const yearAgoBucketStart = new Date(
|
||||
Date.now() - 380 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
function dayBucket(daysAgo: number, count: number, mean: number) {
|
||||
return {
|
||||
bucketStart: startOfUtcDay(
|
||||
new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000),
|
||||
),
|
||||
source: "MANUAL",
|
||||
count,
|
||||
mean,
|
||||
sd: 0,
|
||||
slope: null,
|
||||
r2: null,
|
||||
sumValue: count * mean,
|
||||
minValue: mean,
|
||||
maxValue: mean,
|
||||
sumX: null,
|
||||
sumXy: null,
|
||||
sumXx: null,
|
||||
sumYy: null,
|
||||
computedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
it("reads exactly the 30 days that start 395 days ago", async () => {
|
||||
warmPathMocks();
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: yearAgoBucketStart,
|
||||
count: 10,
|
||||
mean: 85,
|
||||
sd: 1,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
sumValue: null,
|
||||
minValue: 83,
|
||||
maxValue: 87,
|
||||
},
|
||||
dayBucket(390, 10, 85),
|
||||
dayBucket(370, 30, 81),
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-yoy");
|
||||
|
||||
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
|
||||
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR). Call #0
|
||||
// is the v1.20.0 F6 accumulator findMany (DAY); the WMY probe is #1.
|
||||
expect(ROLLUP_FIND_MANY.mock.calls[1][0].where.granularity).toBe("MONTH");
|
||||
expect(result.summaries.WEIGHT.avg30LastYear).toBe(82);
|
||||
const where = ROLLUP_FIND_MANY.mock.calls[1][0].where;
|
||||
expect(where.granularity).toBe("DAY");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
expect(where.bucketStart).toEqual({
|
||||
gte: startOfUtcDay(new Date(Date.now() - 395 * DAY)),
|
||||
lt: startOfUtcDay(new Date(Date.now() - 365 * DAY)),
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]);
|
||||
UNSAFE.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
avg7: 82,
|
||||
avg30: 82.5,
|
||||
slope7: 0,
|
||||
r2_7: 0,
|
||||
slope30: 0,
|
||||
r2_30: 0,
|
||||
slope90: 0,
|
||||
r2_90: 0,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
|
||||
]);
|
||||
|
||||
// v1.20.0 F6 — accumulator findMany runs first; empty so the WMY
|
||||
// probe is call #2.
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
|
||||
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
|
||||
// WEEK 395-day window the router asks for, but outside the
|
||||
// `[now-395d, now-365d)` slice. The helper returns null.
|
||||
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: recentBucket,
|
||||
count: 10,
|
||||
mean: 82,
|
||||
sd: 1,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
sumValue: null,
|
||||
minValue: 80,
|
||||
maxValue: 84,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-recent");
|
||||
|
||||
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]);
|
||||
UNSAFE.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
avg7: 82,
|
||||
avg30: 82.5,
|
||||
slope7: 0,
|
||||
r2_7: 0,
|
||||
slope30: 0,
|
||||
r2_30: 0,
|
||||
slope90: 0,
|
||||
r2_90: 0,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
|
||||
]);
|
||||
|
||||
// Default mock returns `[]` for every findMany call → router
|
||||
// walks YEAR → MONTH → WEEK → DAY and gives up.
|
||||
it("leaves avg30LastYear null when no reading falls in the window", async () => {
|
||||
warmPathMocks();
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeSummariesSlice("user-empty-yoy");
|
||||
|
||||
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
|
||||
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
|
||||
// three reachable tiers all probed on full coverage miss; plus the
|
||||
// v1.20.0 F6 accumulator findMany = 4.
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("only probes types that actually have data in the current window", async () => {
|
||||
|
||||
@@ -11,23 +11,30 @@
|
||||
* This test mirrors the v1.4.40 W-POOL pin
|
||||
* (`src/app/api/analytics/__tests__/route.test.ts` —
|
||||
* "pin concurrency cap and pool ceiling"). It mocks
|
||||
* `readBestGranularityRollups` with a controllable promise so we can
|
||||
* `readRollupBuckets` with a controllable promise so we can
|
||||
* observe the in-flight count at the helper boundary and assert it
|
||||
* never exceeds 4 across a 15-type fan-out.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn(),
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>()),
|
||||
readRollupBuckets: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rollups/measurement-read")>()),
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
WMY_FANOUT_CONCURRENCY,
|
||||
computeAvg30LastYearMap,
|
||||
} from "../summaries-slice";
|
||||
|
||||
const READ = readBestGranularityRollups as unknown as ReturnType<typeof vi.fn>;
|
||||
const READ = readRollupBuckets as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
READ.mockReset();
|
||||
@@ -45,7 +52,7 @@ describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => {
|
||||
expect(WMY_FANOUT_CONCURRENCY).toBe(4);
|
||||
});
|
||||
|
||||
it("holds at most 4 concurrent readBestGranularityRollups calls for a 15-type list", async () => {
|
||||
it("holds at most 4 concurrent rollup reads for a 15-type list", async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const resolvers: Array<() => void> = [];
|
||||
@@ -58,7 +65,7 @@ describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => {
|
||||
inFlight -= 1;
|
||||
// Resolve with `null` — the helper treats no-coverage as
|
||||
// `null` in the output map, which is fine for this test.
|
||||
resolve(null);
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
BUCKET_MINUTES,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -25,14 +25,34 @@
|
||||
* a window aggregate built from WEEK / MONTH buckets equals the per-row
|
||||
* aggregate over the same span. SD / slope / r² are intentionally NOT part of
|
||||
* the delta — they do not compose, matching the rest of the WMY reader tier.
|
||||
*
|
||||
* Two kinds of metric are not averaged per reading:
|
||||
* - step-like totals (steps, energy, distance, flights, …) compare the
|
||||
* average DAILY total: a day's readings are pieces of one total, and the
|
||||
* last day and a half still arrives as many small samples before the
|
||||
* drain folds them, so a per-reading mean collapsed the current window;
|
||||
* - sleep compares the average NIGHT (time asleep, reconstructed from the
|
||||
* stage rows): a per-row mean averaged stage segments and in-bed
|
||||
* envelopes, which is no amount of sleep at all.
|
||||
* For both, `count` is the number of days or nights.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
aggregateWmyBuckets,
|
||||
readBestGranularityRollups,
|
||||
type RollupBucketRow,
|
||||
} from "@/lib/rollups/measurement-read-wmy";
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
ANALYTICS_RANGES,
|
||||
rangeWindowDays,
|
||||
@@ -86,6 +106,99 @@ export function sliceWindowDelta(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate one value per day or night (a daily total, a night's time
|
||||
* asleep): `count` is the number of days or nights, `mean` their average.
|
||||
*/
|
||||
export function aggregatePerPeriod(values: readonly number[]): WindowAggregate {
|
||||
if (values.length === 0) {
|
||||
return { count: 0, min: null, max: null, mean: null, sum: null };
|
||||
}
|
||||
let sum = 0;
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const v of values) {
|
||||
sum += v;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return { count: values.length, min, max, mean: sum / values.length, sum };
|
||||
}
|
||||
|
||||
/** Split `(at, value)` points into the current and previous halves. */
|
||||
function splitPerPeriod(
|
||||
points: ReadonlyArray<{ at: Date; value: number }>,
|
||||
windowDays: number,
|
||||
now: number,
|
||||
): { current: WindowAggregate; previous: WindowAggregate } {
|
||||
const currentStart = now - windowDays * DAY_MS;
|
||||
const previousStart = now - 2 * windowDays * DAY_MS;
|
||||
const current: number[] = [];
|
||||
const previous: number[] = [];
|
||||
for (const p of points) {
|
||||
const t = p.at.getTime();
|
||||
if (t >= currentStart && t < now) current.push(p.value);
|
||||
else if (t >= previousStart && t < currentStart) previous.push(p.value);
|
||||
}
|
||||
return {
|
||||
current: aggregatePerPeriod(current),
|
||||
previous: aggregatePerPeriod(previous),
|
||||
};
|
||||
}
|
||||
|
||||
/** The canonical daily totals of a step-like metric, one point per day. */
|
||||
async function readDailyTotals(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
windowDays: number,
|
||||
now: number,
|
||||
): Promise<Array<{ at: Date; value: number }>> {
|
||||
const rows = await readRollupBuckets(
|
||||
userId,
|
||||
type,
|
||||
"DAY",
|
||||
startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)),
|
||||
new Date(now),
|
||||
);
|
||||
return rows.map((r) => ({ at: r.bucketStart, value: r.mean * r.count }));
|
||||
}
|
||||
|
||||
/** Time asleep per reconstructed night, one point per night. */
|
||||
async function readNightlySleep(
|
||||
userId: string,
|
||||
windowDays: number,
|
||||
now: number,
|
||||
): Promise<Array<{ at: Date; value: number }>> {
|
||||
// A night's rows start the evening before its wake day: read one day more.
|
||||
const rows = (await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
measuredAt: {
|
||||
gte: new Date(now - (2 * windowDays + 1) * DAY_MS),
|
||||
lt: new Date(now),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})) as SleepStageRow[];
|
||||
if (rows.length === 0) return [];
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
return reconstructSleepNights(rows, tz, priorityJson)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => ({ at: n.measuredAt, value: n.asleepMinutes }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the period-over-period delta from the two window aggregates.
|
||||
* Pure — pinned by unit test. Guards both the missing-data case (either
|
||||
@@ -119,6 +232,30 @@ export async function computeRangeDelta(
|
||||
now: number = Date.now(),
|
||||
): Promise<RangeDeltaResult> {
|
||||
const windowDays = rangeWindowDays(range);
|
||||
|
||||
if (type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type)) {
|
||||
const points =
|
||||
type === "SLEEP_DURATION"
|
||||
? await readNightlySleep(userId, windowDays, now)
|
||||
: await readDailyTotals(userId, type, windowDays, now);
|
||||
const { current, previous } = splitPerPeriod(points, windowDays, now);
|
||||
const { delta, deltaPct } = composeDelta(current, previous);
|
||||
return {
|
||||
range,
|
||||
windowDays,
|
||||
granularity:
|
||||
points.length === 0
|
||||
? "none"
|
||||
: type === "SLEEP_DURATION"
|
||||
? "live"
|
||||
: "DAY",
|
||||
current,
|
||||
previous,
|
||||
delta,
|
||||
deltaPct,
|
||||
};
|
||||
}
|
||||
|
||||
// Read the full 2N span in one go so the current and previous halves share
|
||||
// a granularity; the router picks the coarsest tier that resolves 2N.
|
||||
const resolved = await readBestGranularityRollups(
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
* Pure & deterministic — unit-tested in `__tests__/resting-pulse.test.ts`.
|
||||
*/
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/** A timestamped heart-rate sample. */
|
||||
export interface PulseSample {
|
||||
@@ -78,12 +77,11 @@ export const RESTING_PROXY_MIN_DAILY_SAMPLES = 3;
|
||||
export function deriveRestingProxyFromPulse(
|
||||
pulseSamples: ReadonlyArray<PulseSample>,
|
||||
/**
|
||||
* Day-key function used to bucket samples. Defaults to Berlin-day so
|
||||
* direct callers / tests stay zero-config; the targets route passes
|
||||
* its own `userDayKey(d, userTz)` so the proxy buckets align with the
|
||||
* route's consistency-strip day buckets for non-Berlin users.
|
||||
* Day-key function used to bucket samples: the user's own day
|
||||
* (`userDayKey(d, userTz)`), so the proxy buckets line up with every other
|
||||
* day bucket the user sees.
|
||||
*/
|
||||
dayKeyOf: (d: Date) => string = toBerlinDayKey,
|
||||
dayKeyOf: (d: Date) => string,
|
||||
): PulseSample[] {
|
||||
if (pulseSamples.length === 0) return [];
|
||||
const byDay = new Map<string, { firstAt: Date; values: number[] }>();
|
||||
@@ -139,10 +137,10 @@ export function deriveRestingProxyFromPulse(
|
||||
export function resolveRestingPulseSeries(input: {
|
||||
restingSamples: ReadonlyArray<PulseSample>;
|
||||
pulseSamples: ReadonlyArray<PulseSample>;
|
||||
/** Optional day-key for the proxy buckets (defaults to Berlin-day). */
|
||||
dayKeyOf?: (d: Date) => string;
|
||||
/** Day key for the proxy buckets: the user's own day. */
|
||||
dayKeyOf: (d: Date) => string;
|
||||
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
|
||||
const dayKeyOf = input.dayKeyOf ?? toBerlinDayKey;
|
||||
const dayKeyOf = input.dayKeyOf;
|
||||
const byTime = (a: PulseSample, b: PulseSample) =>
|
||||
a.measuredAt.getTime() - b.measuredAt.getTime();
|
||||
|
||||
|
||||
@@ -25,6 +25,16 @@ function days(count: number, value: number) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** `count` completed days ending yesterday (activity leaves today out). */
|
||||
function completedDays(count: number, value: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
day: new Date(NOW.getTime() - (index + 1) * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("reference-score pillars", () => {
|
||||
it("requires twelve fresh paired blood-pressure readings", () => {
|
||||
const below = computeBloodPressurePillar({
|
||||
@@ -174,7 +184,7 @@ describe("reference-score pillars", () => {
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
days: days(20, 10_000),
|
||||
days: completedDays(20, 10_000),
|
||||
sources: ["APPLE_HEALTH"],
|
||||
}).status,
|
||||
).toBe("insufficient");
|
||||
@@ -183,14 +193,14 @@ describe("reference-score pillars", () => {
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
days: days(21, 12_000),
|
||||
days: completedDays(21, 12_000),
|
||||
sources: ["APPLE_HEALTH"],
|
||||
});
|
||||
const older = computeActivityPillar({
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 68,
|
||||
days: days(21, 8_000),
|
||||
days: completedDays(21, 8_000),
|
||||
sources: ["APPLE_HEALTH"],
|
||||
});
|
||||
expect(younger.status).toBe("ok");
|
||||
@@ -209,7 +219,7 @@ describe("reference-score pillars", () => {
|
||||
asOf: NOW,
|
||||
timezone: "Europe/Berlin",
|
||||
ageYears: 40,
|
||||
days: days(21, 8_500),
|
||||
days: completedDays(21, 8_500),
|
||||
sources: ["APPLE_HEALTH"],
|
||||
};
|
||||
const liveResult = computeActivityPillar({ ...input, source: "live" });
|
||||
@@ -224,7 +234,7 @@ describe("reference-score pillars", () => {
|
||||
});
|
||||
|
||||
it("scores exactly 28 activity calendar keys", () => {
|
||||
const points = days(29, 10_000);
|
||||
const points = completedDays(29, 10_000);
|
||||
points[28] = { ...points[28], value: 0 };
|
||||
const result = computeActivityPillar({
|
||||
...live,
|
||||
@@ -237,6 +247,64 @@ describe("reference-score pillars", () => {
|
||||
if (result.status === "ok") expect(result.value.score).toBe(100);
|
||||
});
|
||||
|
||||
it("leaves today's partial step total out of the window", () => {
|
||||
const withToday = [
|
||||
{ day: "2026-07-28", value: 400 },
|
||||
...completedDays(21, 10_000),
|
||||
];
|
||||
const result = computeActivityPillar({
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
days: withToday,
|
||||
sources: ["APPLE_HEALTH"],
|
||||
});
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") expect(result.value.score).toBe(100);
|
||||
|
||||
// Twenty completed days plus today are not enough days.
|
||||
expect(
|
||||
computeActivityPillar({
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
days: [{ day: "2026-07-28", value: 400 }, ...completedDays(20, 10_000)],
|
||||
sources: ["APPLE_HEALTH"],
|
||||
}).status,
|
||||
).toBe("insufficient");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["America/New_York", "2026-03-09T03:30:00.000Z", "2026-03-08"],
|
||||
["Pacific/Auckland", "2026-04-05T11:00:00.000Z", "2026-04-05"],
|
||||
["Pacific/Tongatapu", "2026-07-28T11:30:00.000Z", "2026-07-29"],
|
||||
["America/Santiago", "2026-09-06T04:30:00.000Z", "2026-09-06"],
|
||||
["Europe/Berlin", "2026-10-25T23:30:00.000Z", "2026-10-26"],
|
||||
])("cuts the window on local days in %s", (timezone, iso, today) => {
|
||||
const asOf = new Date(iso);
|
||||
const [y, m, d] = today.split("-").map(Number);
|
||||
const key = (back: number) =>
|
||||
new Date(Date.UTC(y, m - 1, d - back)).toISOString().slice(0, 10);
|
||||
const window = Array.from({ length: 28 }, (_, i) => ({
|
||||
day: key(i + 1),
|
||||
value: 10_000,
|
||||
}));
|
||||
const result = computeActivityPillar({
|
||||
source: "live",
|
||||
readFailed: false,
|
||||
timezone,
|
||||
asOf,
|
||||
ageYears: 40,
|
||||
days: [{ day: today, value: 0 }, { day: key(29), value: 0 }, ...window],
|
||||
sources: ["APPLE_HEALTH"],
|
||||
});
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.value.score).toBe(100);
|
||||
expect(result.coverage.historyDays).toBe(28);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires fourteen sleep nights, never penalises long sleep, and adds regularity at 21 nights", () => {
|
||||
const below = computeSleepPillar({
|
||||
...live,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import type { ActivityPillarInput, PillarValue } from "./types";
|
||||
import {
|
||||
@@ -18,6 +18,23 @@ export const OLDER_ADULT_AGE = 60;
|
||||
export const YOUNGER_STEP_PLATEAU = 10_000;
|
||||
export const OLDER_STEP_PLATEAU = 8_000;
|
||||
|
||||
/**
|
||||
* The activity window: the `ACTIVITY_WINDOW_DAYS` completed local days that
|
||||
* end yesterday, as inclusive `YYYY-MM-DD` keys. Today is left out because
|
||||
* its step total is still growing: counting it pulled every score computed
|
||||
* during the day down by a partial day.
|
||||
*/
|
||||
export function activityWindowKeys(
|
||||
asOf: Date,
|
||||
timezone: string,
|
||||
): { startDay: string; endDay: string } {
|
||||
const today = userDayKey(asOf, timezone);
|
||||
return {
|
||||
startDay: shiftDateKey(today, -ACTIVITY_WINDOW_DAYS),
|
||||
endDay: shiftDateKey(today, -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function computeActivityPillar(
|
||||
input: ActivityPillarInput,
|
||||
): Derived<PillarValue> {
|
||||
@@ -29,13 +46,9 @@ export function computeActivityPillar(
|
||||
});
|
||||
}
|
||||
|
||||
const asOfDay = userDayKey(input.asOf, input.timezone);
|
||||
const sinceDay = userDayKey(
|
||||
new Date(input.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * 86_400_000),
|
||||
input.timezone,
|
||||
);
|
||||
const { startDay, endDay } = activityWindowKeys(input.asOf, input.timezone);
|
||||
const points = input.days.filter(
|
||||
(point) => point.day <= asOfDay && point.day >= sinceDay,
|
||||
(point) => point.day <= endDay && point.day >= startDay,
|
||||
);
|
||||
if (points.length < ACTIVITY_MIN_DAYS) {
|
||||
return insufficientPillar({
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
import { ACTIVITY_WINDOW_DAYS } from "./activity";
|
||||
import { ACTIVITY_WINDOW_DAYS, activityWindowKeys } from "./activity";
|
||||
import { SLEEP_WINDOW_DAYS } from "./sleep";
|
||||
import { attachScoreDelta } from "./composite";
|
||||
import {
|
||||
@@ -239,9 +239,8 @@ export async function readActivityDays(args: {
|
||||
canonical
|
||||
.filter((row) => {
|
||||
const day = userDayKey(row.measuredAt, args.timezone);
|
||||
const endDay = userDayKey(args.asOf, args.timezone);
|
||||
const startDay = userDayKey(
|
||||
new Date(args.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS),
|
||||
const { startDay, endDay } = activityWindowKeys(
|
||||
args.asOf,
|
||||
args.timezone,
|
||||
);
|
||||
return day >= startDay && day <= endDay;
|
||||
@@ -461,10 +460,7 @@ function scoreInputsFor(args: {
|
||||
const { asOf, input } = args;
|
||||
const ageYears = getAgeFromDateOfBirth(input.profile.dateOfBirth);
|
||||
const asOfDay = userDayKey(asOf, input.profile.timezone);
|
||||
const activitySinceDay = userDayKey(
|
||||
new Date(asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS),
|
||||
input.profile.timezone,
|
||||
);
|
||||
const activityWindow = activityWindowKeys(asOf, input.profile.timezone);
|
||||
const sleepSinceDay = userDayKey(
|
||||
new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS),
|
||||
input.profile.timezone,
|
||||
@@ -548,7 +544,11 @@ function scoreInputsFor(args: {
|
||||
days: args.activity.value.days,
|
||||
sources: uniqueSources(
|
||||
args.activity.value.sourceDays
|
||||
.filter((row) => row.day >= activitySinceDay && row.day <= asOfDay)
|
||||
.filter(
|
||||
(row) =>
|
||||
row.day >= activityWindow.startDay &&
|
||||
row.day <= activityWindow.endDay,
|
||||
)
|
||||
.map((row) => row.source),
|
||||
),
|
||||
},
|
||||
@@ -629,7 +629,7 @@ export async function computeUserHealthScore(
|
||||
const db = input.prisma ?? prisma;
|
||||
const since = new Date(input.now.getTime() - SCORE_READ_DAYS * DAY_MS);
|
||||
const activitySince = new Date(
|
||||
input.now.getTime() - (28 + HISTORY_OFFSET_DAYS) * DAY_MS,
|
||||
input.now.getTime() - (ACTIVITY_WINDOW_DAYS + HISTORY_OFFSET_DAYS) * DAY_MS,
|
||||
);
|
||||
const [
|
||||
glucose,
|
||||
|
||||
@@ -147,7 +147,7 @@ export function canonicalMeasurementsCte(
|
||||
WHERE "user_id" = $1
|
||||
AND "deleted_at" IS NULL
|
||||
${sinceInner}
|
||||
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"
|
||||
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
|
||||
) c
|
||||
ON c.t = mm."type"
|
||||
AND c.d = date_trunc('day', mm."measured_at")
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
@@ -64,9 +65,11 @@ import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
readRollupBuckets,
|
||||
ROLLUP_FOLD_WINDOW_MS,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
aggregateBuckets,
|
||||
collapseRollupRowsBySource,
|
||||
composeWindowedRegression,
|
||||
hasPendingAccumulatorBackfill,
|
||||
@@ -83,7 +86,6 @@ import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
type SleepSourceDiscrepancy,
|
||||
@@ -423,6 +425,11 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
'"source"',
|
||||
);
|
||||
const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
|
||||
const rankPerSource = buildSourceRankCase(
|
||||
priorityJson,
|
||||
'p."type"',
|
||||
'p."source"',
|
||||
);
|
||||
|
||||
// v1.37.19 (A6-10) — where the fold window starts. DAY rollup buckets
|
||||
// only exist inside the trailing `ROLLUP_FOLD_WINDOW_MS` (5 y), so the
|
||||
@@ -514,7 +521,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
WHERE "user_id" = $1
|
||||
AND "granularity" = 'DAY'
|
||||
AND "bucket_start" >= $2
|
||||
ORDER BY "type", "bucket_start", (${rankUnqualified}), "source"
|
||||
ORDER BY "type", "bucket_start", (${rankUnqualified}), "source"::text
|
||||
)
|
||||
SELECT
|
||||
"type"::text AS type,
|
||||
@@ -559,16 +566,15 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
sumYy: true,
|
||||
},
|
||||
}),
|
||||
// v1.37.19 (A6-10) — all-time remainder OLDER than the fold window.
|
||||
// One grouped aggregate over the pre-fold rows (index range scan on
|
||||
// `(user_id, measured_at)`; empty for any account younger than the
|
||||
// window). Spliced into the per-type figures below so "all-time"
|
||||
// means all time on the rollup path too. Deliberately without the
|
||||
// source-priority collapse: pre-fold history predates the rollup
|
||||
// tier's per-day canonicalisation, and a per-day DISTINCT ON over a
|
||||
// multi-year span is the cost this splice exists to avoid — for
|
||||
// count/min/max/mean over years-old data the raw figures are the
|
||||
// honest ones.
|
||||
// v1.37.19 (A6-10) — all-time remainder OLDER than the fold window,
|
||||
// spliced into the per-type figures below so "all-time" means all time
|
||||
// on the rollup path too (empty for any account younger than the
|
||||
// window). It keeps one source per (type, day) the way the rollup half
|
||||
// and the live path do: without it, a vital recorded by two devices
|
||||
// counted both of them on every pre-fold day. Each source is folded per
|
||||
// day first (a hash aggregate, a few rows per day however dense the
|
||||
// stream), each day then keeps its ladder-canonical source, so the
|
||||
// sort never sees more than one row per source per day.
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
@@ -578,17 +584,35 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
mean: number;
|
||||
}>
|
||||
>`
|
||||
WITH per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', m."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(m."value")::double precision AS total,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldSplit}
|
||||
GROUP BY m."type", m."source", 3
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value
|
||||
FROM per_source p
|
||||
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*)::int AS count,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
AVG(m."value")::double precision AS mean
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldSplit}
|
||||
GROUP BY m."type"
|
||||
c."type"::text AS type,
|
||||
SUM(c.cnt)::int AS count,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max,
|
||||
(SUM(c.total) / SUM(c.cnt))::double precision AS mean
|
||||
FROM canon c
|
||||
GROUP BY c."type"
|
||||
`,
|
||||
]);
|
||||
|
||||
@@ -773,11 +797,10 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the
|
||||
// WMY rollup tier. Only types with data in the current window are
|
||||
// probed so we don't pay the per-type round-trip on the long tail
|
||||
// of unlogged measurement enums. Types without YEAR/MONTH coverage
|
||||
// surface as `null` (existing behaviour) — additive only.
|
||||
// Populate `avg30LastYear` per type from the DAY rollup tier. Only types
|
||||
// with data in the current window are probed so we don't pay the per-type
|
||||
// round-trip on the long tail of unlogged measurement enums. Types with no
|
||||
// reading in the year-ago window surface as `null`.
|
||||
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
|
||||
let yearOverYearTypeCount = 0;
|
||||
for (const [type, value] of avg30LastYearMap.entries()) {
|
||||
@@ -1016,13 +1039,10 @@ async function computeFromLiveAggregate(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.40 W-WMY-WIRE — even on the live-aggregate fallback (DAY
|
||||
// coverage incomplete), the YEAR / MONTH rollup tier may still
|
||||
// carry the year-ago baseline because the boot-time backfill mints
|
||||
// all granularities once per user. Probing here means a partial-
|
||||
// coverage cold mount still surfaces `avg30LastYear` whenever the
|
||||
// long-tail tier is populated, instead of waiting for the next
|
||||
// refresh cycle.
|
||||
// Even on the live-aggregate fallback (trailing DAY coverage incomplete),
|
||||
// the year-ago DAY buckets may already exist because the boot-time
|
||||
// backfill folds the whole history once per user, so a partial-coverage
|
||||
// cold mount still surfaces `avg30LastYear`.
|
||||
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
|
||||
let yearOverYearTypeCount = 0;
|
||||
for (const [type, value] of avg30LastYearMap.entries()) {
|
||||
@@ -1051,118 +1071,51 @@ async function computeFromLiveAggregate(
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.39 W-WMY — long-window summary for a single `(userId, type)`
|
||||
* pair, served from the WEEK / MONTH / YEAR rollup tier when the
|
||||
* window justifies it.
|
||||
* Year-ago 30-day baseline per type, served from the DAY rollup tier.
|
||||
*
|
||||
* Why this helper exists
|
||||
* ----------------------
|
||||
* The slim `computeSummariesSlice` caps its windowed columns at 90 d
|
||||
* (`slope7 / slope30`). The v1.5 multi-year trend feature
|
||||
* + the Coach drawer's "history" tile need linearly composable stats
|
||||
* (count / min / max / mean / sum) over much larger windows — 1 y,
|
||||
* 2 y, 3 y. Hitting the live `measurements` table for a 3-year span
|
||||
* walks every row the user has ever logged for that type, which on
|
||||
* the maintainer's tenant is tens of thousands of rows per type.
|
||||
* `DataSummary.avg30LastYear` is the mean over the 30 days that start 395
|
||||
* days ago, which the dashboard tile uses for "current average vs the same
|
||||
* time last year". The window is exactly those 30 UTC days (the DAY-tier
|
||||
* grain), read as canonical-source DAY buckets and composed count-weighted,
|
||||
* so it is the same per-reading mean `avg30` reports for the last 30 days.
|
||||
*
|
||||
* The WMY rollup tier already carries the per-bucket stats we need:
|
||||
* the writer mints WEEK / MONTH / YEAR buckets via pg-boss on every
|
||||
* measurement write and the boot backfill fills the long tail. This
|
||||
* helper routes the requested window into the largest granularity
|
||||
* that still resolves it (via `readBestGranularityRollups`) and
|
||||
* composes the trailing-window aggregate in JS — typically 3-15
|
||||
* bucket rows on a multi-year window, vs the thousands of raw rows
|
||||
* the live aggregator would scan.
|
||||
* Up to v1.39.2 this read the MONTH tier and kept the months whose first day
|
||||
* fell inside the window, so the baseline was a whole calendar month that
|
||||
* could start up to a month off, or nothing at all when no month began inside
|
||||
* the window.
|
||||
*
|
||||
* Compositional caveats
|
||||
* ---------------------
|
||||
* `count / min / max / mean / sum` are mathematically exact across
|
||||
* any granularity (linearly composable). `sd / slope / r2` are NOT
|
||||
* exact across coarser buckets — this helper deliberately omits them
|
||||
* so callers cannot accidentally consume stale slope data. The v1.5
|
||||
* multi-year trend card derives its slope from the per-bucket `mean`
|
||||
* series instead.
|
||||
*
|
||||
* Coverage-miss policy
|
||||
* --------------------
|
||||
* `readBestGranularityRollups` falls back from YEAR → MONTH → WEEK
|
||||
* → DAY on per-tier coverage miss. Returns `null` only when the
|
||||
* user has zero buckets in the entire window — the caller treats
|
||||
* that as "no data" rather than "stale rollup". The route is
|
||||
* intentionally NOT wired to live SQL fallback here; the all-time
|
||||
* aggregate path inside `computeSummariesSlice` already covers the
|
||||
* "rollup tier empty" cold-mount case, and v1.5's multi-year card
|
||||
* accepts a `null` "no data yet" state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
|
||||
* the WEEK / MONTH / YEAR rollup tier.
|
||||
*
|
||||
* Why this helper exists
|
||||
* ----------------------
|
||||
* `DataSummary.avg30LastYear` is the mean over `[now-395d, now-365d)` —
|
||||
* the 30-day window starting 365 days ago. The dashboard tile-strip's
|
||||
* delta callout uses it to narrate "current avg vs same time last
|
||||
* year". Up to v1.4.39 the slim slice (and the comprehensive
|
||||
* aggregator's rollup branch) hardcoded the field to `null` because
|
||||
* the 90-day windowed `$queryRaw` cannot reach back a year, and the
|
||||
* live `measurements` table walk for a 395-day per-type window is
|
||||
* exactly the cold-path cost we're trying to avoid.
|
||||
*
|
||||
* The WMY rollup tier already carries the per-bucket stats this
|
||||
* window needs. `readBestGranularityRollups(userId, type, 395)`
|
||||
* routes the 395-day window through YEAR (if covered) → MONTH → WEEK
|
||||
* → DAY and returns the buckets whose `bucketStart` falls inside the
|
||||
* window. We then keep only the buckets that overlap the
|
||||
* `[now-395d, now-365d)` slice and compose `count / mean` linearly to
|
||||
* produce the year-ago baseline.
|
||||
*
|
||||
* Compositional contract
|
||||
* ----------------------
|
||||
* `count / mean` are linearly composable across any granularity, so
|
||||
* the year-ago mean derived from MONTH or YEAR buckets is
|
||||
* mathematically equivalent to the per-row average over the same
|
||||
* window. The bucket-overlap filter is conservative: we include only
|
||||
* buckets whose `bucketStart` is strictly inside the slice. On a
|
||||
* YEAR-granularity routing this collapses to at most one bucket and
|
||||
* approximates the 30-day mean by the surrounding yearly average —
|
||||
* acceptable for a UI hint that explicitly narrates "last year".
|
||||
*
|
||||
* Coverage-miss policy
|
||||
* --------------------
|
||||
* Returns `null` when `readBestGranularityRollups` returns null (no
|
||||
* coverage at any granularity) OR when no buckets overlap the
|
||||
* year-ago slice. The caller leaves the dashboard field as `null`,
|
||||
* which the UI already handles as "no comparison available".
|
||||
* Returns `null` when no reading falls in the window; the UI treats that as
|
||||
* "no comparison available".
|
||||
*/
|
||||
async function computeAvg30LastYearForType(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
priorityJson: unknown,
|
||||
now: number,
|
||||
): Promise<number | null> {
|
||||
const resolved = await readBestGranularityRollups(userId, type, 395);
|
||||
if (!resolved) return null;
|
||||
const now = Date.now();
|
||||
const sliceStart = now - 395 * DAY_MS;
|
||||
const sliceEnd = now - 365 * DAY_MS;
|
||||
const overlapping = resolved.rows.filter((row) => {
|
||||
const t = row.bucketStart.getTime();
|
||||
return t >= sliceStart && t < sliceEnd;
|
||||
});
|
||||
if (overlapping.length === 0) return null;
|
||||
let totalCount = 0;
|
||||
let weighted = 0;
|
||||
for (const row of overlapping) {
|
||||
totalCount += row.count;
|
||||
weighted += row.count * row.mean;
|
||||
}
|
||||
if (totalCount === 0) return null;
|
||||
return weighted / totalCount;
|
||||
const rows = await readRollupBuckets(
|
||||
userId,
|
||||
type,
|
||||
"DAY",
|
||||
startOfUtcDay(new Date(now - 395 * DAY_MS)),
|
||||
startOfUtcDay(new Date(now - 365 * DAY_MS)),
|
||||
priorityJson,
|
||||
);
|
||||
const composed = aggregateBuckets(
|
||||
rows.map((r) => ({
|
||||
day: r.bucketStart,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
minValue: r.minValue,
|
||||
maxValue: r.maxValue,
|
||||
})),
|
||||
);
|
||||
return composed.count > 0 ? composed.mean : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across
|
||||
* the types the caller actually surfaces. Runs the per-type WMY reads
|
||||
* the types the caller actually surfaces. Runs the per-type rollup reads
|
||||
* in parallel so the long-tail of types doesn't serialise into a
|
||||
* per-type round-trip stack.
|
||||
*
|
||||
@@ -1191,12 +1144,16 @@ export async function computeAvg30LastYearMap(
|
||||
const out = new Map<string, number | null>();
|
||||
if (types.length === 0) return out;
|
||||
const limit = pLimit(WMY_FANOUT_CONCURRENCY);
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const now = Date.now();
|
||||
const results = await Promise.all(
|
||||
types.map((type) =>
|
||||
limit(async () => {
|
||||
const value = await computeAvg30LastYearForType(
|
||||
userId,
|
||||
type as MeasurementType,
|
||||
priorityJson,
|
||||
now,
|
||||
);
|
||||
return [type, value] as const;
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { heatmapDays } from "../heatmap-days";
|
||||
|
||||
describe("heatmapDays", () => {
|
||||
it.each([
|
||||
// 20:30 in New York is already the next UTC day.
|
||||
["America/New_York", "2026-03-09T00:30:00.000Z", "2026-03-08"],
|
||||
// 08:00 in Auckland and Tonga is still the previous UTC day.
|
||||
["Pacific/Auckland", "2026-04-04T20:00:00.000Z", "2026-04-05"],
|
||||
["Pacific/Tongatapu", "2026-07-27T19:00:00.000Z", "2026-07-28"],
|
||||
["America/Santiago", "2026-09-06T03:30:00.000Z", "2026-09-05"],
|
||||
["Europe/Berlin", "2026-10-25T22:30:00.000Z", "2026-10-25"],
|
||||
])("ends on today's key in %s", (tz, iso, today) => {
|
||||
const days = heatmapDays(new Date(iso), tz, 14);
|
||||
expect(days).toHaveLength(14);
|
||||
expect(days.at(-1)?.dateKey).toBe(today);
|
||||
// Consecutive keys, one per day, no gap or repeat across DST.
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = Date.parse(`${days[i - 1].dateKey}T00:00:00Z`);
|
||||
const cur = Date.parse(`${days[i].dateKey}T00:00:00Z`);
|
||||
expect(cur - prev).toBe(86_400_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads weekday and month off the key", () => {
|
||||
const [last] = heatmapDays(
|
||||
new Date("2026-03-01T03:00:00.000Z"),
|
||||
"America/New_York",
|
||||
1,
|
||||
);
|
||||
// 22:00 on Saturday 28 February in New York.
|
||||
expect(last).toEqual({ dateKey: "2026-02-28", dow: 5, month: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/** One calendar-heatmap column entry: a local day and where it sits. */
|
||||
export interface HeatmapDay {
|
||||
/** `YYYY-MM-DD` in the user's zone. */
|
||||
dateKey: string;
|
||||
/** Weekday with Monday = 0 … Sunday = 6. */
|
||||
dow: number;
|
||||
/** Month index 0–11. */
|
||||
month: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last `days` local days in `timeZone`, oldest first, ending with today.
|
||||
*
|
||||
* Built from today's key in the user's zone and stepped on keys. The heatmaps
|
||||
* used to take `now − n·24 h` and cut the key in UTC, so in the evening west
|
||||
* of UTC (or the morning east of it) "today" was a different day from the one
|
||||
* the server had keyed the user's readings on, and the newest cell showed the
|
||||
* wrong day. Weekday and month come from the key itself.
|
||||
*/
|
||||
export function heatmapDays(
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
days: number,
|
||||
): HeatmapDay[] {
|
||||
const today = userDayKey(now, timeZone);
|
||||
const out: HeatmapDay[] = [];
|
||||
for (let back = days - 1; back >= 0; back--) {
|
||||
const dateKey = shiftDateKey(today, -back);
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
const utc = new Date(Date.UTC(y, m - 1, d));
|
||||
out.push({ dateKey, dow: (utc.getUTCDay() + 6) % 7, month: m - 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -63,6 +63,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
@@ -118,6 +119,36 @@ beforeEach(() => {
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
|
||||
});
|
||||
|
||||
describe("loadDailyDigest — milestone day", () => {
|
||||
// 21:30 on 16 July in New York is already 17 July in UTC. A best set at
|
||||
// 19:00 local that evening was reached today, on the user's calendar.
|
||||
const NY_USER = { ...USER, timezone: "America/New_York" } as User;
|
||||
const NY_EVENING = new Date("2026-07-17T01:30:00.000Z");
|
||||
|
||||
it("keys today and a new record on the user's own day", async () => {
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
metricType: "RESTING_HEART_RATE",
|
||||
achievedAt: new Date("2026-07-16T23:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(NY_USER, NY_EVENING);
|
||||
|
||||
expect(digest.worthALook.some((i) => i.kind === "milestone")).toBe(true);
|
||||
});
|
||||
|
||||
it("folds the streak series on the user's days", async () => {
|
||||
vi.mocked(probeRollupCoverage).mockResolvedValue(
|
||||
new Map([["RESTING_HEART_RATE", true]]) as never,
|
||||
);
|
||||
await loadDailyDigest(NY_USER, NY_EVENING);
|
||||
const calls = vi.mocked(readDayMeanSeries).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const call of calls) expect(call[5]).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadDailyDigest — S11/S12 extras cache", () => {
|
||||
it("gathers the milestone + tension inputs once, then serves the second poll from cache", async () => {
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -101,11 +101,6 @@ const MILESTONE_STREAK_WINDOW_DAYS = 30;
|
||||
/** How far back to scan for a just-set personal best (a couple of days is ample). */
|
||||
const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** UTC-ISO day key — the space the streak series + rollup tier already emit. */
|
||||
function utcDayKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* S12 — gather today's single freshly-reached milestone (or null) from the
|
||||
* engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim:
|
||||
@@ -117,9 +112,13 @@ function utcDayKey(date: Date): string {
|
||||
*/
|
||||
async function gatherFreshMilestone(
|
||||
userId: string,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): Promise<Milestone | null> {
|
||||
const todayKey = utcDayKey(now);
|
||||
// Every day key here is the user's own calendar day: today's key, the
|
||||
// streak series (folded on local days) and each record's day. UTC keys
|
||||
// named tomorrow "today" through a New York evening.
|
||||
const todayKey = userDayKey(now, timezone);
|
||||
const candidates: Milestone[] = [];
|
||||
|
||||
const coverage = await probeRollupCoverage(userId).catch(() => null);
|
||||
@@ -133,6 +132,7 @@ async function gatherFreshMilestone(
|
||||
MILESTONE_STREAK_WINDOW_DAYS,
|
||||
now,
|
||||
coverage,
|
||||
timezone,
|
||||
);
|
||||
if (points.length === 0) return [] as Milestone[];
|
||||
const series: StreakPoint[] = points.map((p) => ({
|
||||
@@ -164,7 +164,10 @@ async function gatherFreshMilestone(
|
||||
});
|
||||
for (const record of records) {
|
||||
candidates.push(
|
||||
milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)),
|
||||
milestoneFromRecord(
|
||||
record.metricType,
|
||||
userDayKey(record.achievedAt, timezone),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -234,7 +237,7 @@ async function loadDailyDigestExtrasCached(
|
||||
// internally (a milestone read-hiccup or a tension-read failure leaves
|
||||
// its own field quiet rather than breaking the other).
|
||||
const [milestone, tensionWindow, sameTime] = await Promise.all([
|
||||
gatherFreshMilestone(userId, now),
|
||||
gatherFreshMilestone(userId, timezone, now),
|
||||
loadIntradayPulse(userId, timezone, todayLocalDate)
|
||||
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
|
||||
.catch(() => null),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeStreak } from "../streak";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const ZONES = [
|
||||
"America/New_York",
|
||||
"America/Santiago",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Tongatapu",
|
||||
"Europe/Berlin",
|
||||
"UTC",
|
||||
];
|
||||
|
||||
/** The last `n` local days ending with today's key in `tz`. */
|
||||
function lastDays(now: Date, tz: string, n: number, skipToday = false) {
|
||||
const keys = new Set<string>();
|
||||
const today = userDayKey(now, tz);
|
||||
const [y, m, d] = today.split("-").map(Number);
|
||||
for (let i = skipToday ? 1 : 0; i < n + (skipToday ? 1 : 0); i++) {
|
||||
keys.add(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
describe("computeStreak", () => {
|
||||
// Evening in New York is already the next UTC day, and the early hours in
|
||||
// Auckland or Tonga are still the previous UTC day: both ends of the walk.
|
||||
const INSTANTS = [
|
||||
"2026-03-08T01:30:00.000Z",
|
||||
"2026-03-08T23:30:00.000Z",
|
||||
"2026-04-05T13:00:00.000Z",
|
||||
"2026-09-06T03:30:00.000Z",
|
||||
"2026-10-25T00:30:00.000Z",
|
||||
"2026-11-01T06:00:00.000Z",
|
||||
];
|
||||
|
||||
for (const tz of ZONES) {
|
||||
for (const iso of INSTANTS) {
|
||||
const now = new Date(iso);
|
||||
it(`counts three logged days ending today in ${tz} at ${iso}`, () => {
|
||||
const streak = computeStreak(lastDays(now, tz, 3), tz, now);
|
||||
expect(streak.currentDays).toBe(3);
|
||||
expect(streak.longest).toBe(3);
|
||||
});
|
||||
it(`counts from yesterday when today is not logged yet in ${tz} at ${iso}`, () => {
|
||||
const streak = computeStreak(lastDays(now, tz, 4, true), tz, now);
|
||||
expect(streak.currentDays).toBe(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("breaks the current streak on a gap", () => {
|
||||
const now = new Date("2026-06-10T20:00:00.000Z");
|
||||
const tz = "America/New_York";
|
||||
const streak = computeStreak(
|
||||
new Set([
|
||||
"2026-06-10",
|
||||
"2026-06-09",
|
||||
"2026-06-07",
|
||||
"2026-06-06",
|
||||
"2026-06-05",
|
||||
]),
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
expect(streak.currentDays).toBe(2);
|
||||
expect(streak.longest).toBe(3);
|
||||
});
|
||||
|
||||
it("returns zeros for no activity", () => {
|
||||
expect(computeStreak(new Set(), "UTC")).toEqual({
|
||||
currentDays: 0,
|
||||
longest: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,16 +25,12 @@
|
||||
* prefetch-off / legacy / fail-soft paths where no prop arrives.
|
||||
*
|
||||
* Import-safe from the server RSC and the client component alike: pure, no
|
||||
* Prisma / db / `node:*` imports (only the client-safe `@/lib/tz/format`
|
||||
* Intl helpers and the layout type).
|
||||
* Prisma / db / `node:*` imports (only the client-safe `@/lib/tz` Intl
|
||||
* helpers and the layout type).
|
||||
*/
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
tzOffsetMinutes,
|
||||
userDayKey,
|
||||
} from "@/lib/tz/format";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
|
||||
/**
|
||||
* Day-span the batched dashboard series (`series-batch`) fetches. Threaded to
|
||||
@@ -97,21 +93,13 @@ export interface BatchWindow {
|
||||
}
|
||||
|
||||
/**
|
||||
* End-of-day of `now` in `timezone`, as a UTC instant. Falls back to the
|
||||
* project default zone for an unusable IANA id. Two-step offset refine so the
|
||||
* rare DST edge (a transition between the as-if-UTC guess and the true local
|
||||
* instant) still resolves to the right offset.
|
||||
* Last millisecond of the local day `now` falls on in `timezone`, as a UTC
|
||||
* instant. Falls back to the project default zone for an unusable IANA id.
|
||||
*/
|
||||
function endOfLocalDayUtc(now: Date, timezone: string): Date {
|
||||
const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE;
|
||||
const dayKey = userDayKey(now, safeTz); // YYYY-MM-DD in the zone
|
||||
const [year, month, day] = dayKey.split("-").map(Number);
|
||||
// Wall-clock 23:59:59.999 treated as-if-UTC, then shifted by the zone offset.
|
||||
const asIfUtc = Date.UTC(year, month - 1, day, 23, 59, 59, 999);
|
||||
const offset1 = tzOffsetMinutes(new Date(asIfUtc), safeTz);
|
||||
const corrected = new Date(asIfUtc - offset1 * 60_000);
|
||||
const offset2 = tzOffsetMinutes(corrected, safeTz);
|
||||
return new Date(asIfUtc - offset2 * 60_000);
|
||||
const { dayEnd } = localDayWindow(userDayKey(now, safeTz), safeTz);
|
||||
return new Date(dayEnd.getTime() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current logging-day streak (days where any measurement or intake
|
||||
* event was recorded, in the user's zone) plus the longest streak among
|
||||
* `activityDays`.
|
||||
*
|
||||
* `activityDays` holds `YYYY-MM-DD` keys already cut in `userTz`. The walk
|
||||
* steps through day KEYS (`shiftDateKey`), never instants: stepping back from
|
||||
* UTC midnight of today's key read that instant in the user's zone, which
|
||||
* west of UTC is still yesterday, so the streak started one day early and
|
||||
* came out one day short. Yesterday counts as the streak's head when today
|
||||
* has not been logged yet.
|
||||
*/
|
||||
export function computeStreak(
|
||||
activityDays: ReadonlySet<string>,
|
||||
userTz: string,
|
||||
now: Date = new Date(),
|
||||
): StreakInfo {
|
||||
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
|
||||
|
||||
const sorted = [...activityDays].sort();
|
||||
let longest = 1;
|
||||
let run = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (shiftDateKey(sorted[i - 1], 1) === sorted[i]) {
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = userDayKey(now, userTz);
|
||||
if (!activityDays.has(cursor)) cursor = shiftDateKey(cursor, -1);
|
||||
let currentDays = 0;
|
||||
while (activityDays.has(cursor)) {
|
||||
currentDays += 1;
|
||||
cursor = shiftDateKey(cursor, -1);
|
||||
}
|
||||
return { currentDays, longest };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -189,7 +189,34 @@ export async function collectDoctorReportData(
|
||||
}),
|
||||
wantsMedications
|
||||
? prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// The report describes its window, and a link can pin that window
|
||||
// in the past: a medication whose course had not begun by the
|
||||
// window's end, or had ended before it began, is not part of it.
|
||||
// One with a dose scheduled inside the window was taken then,
|
||||
// whenever it was entered. Dose history and the last intake stop
|
||||
// at the window's end too, so a report for March does not show a
|
||||
// dose set in June.
|
||||
where: {
|
||||
userId,
|
||||
active: true,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ startsOn: { lte: end } },
|
||||
{ startsOn: null, createdAt: { lte: end } },
|
||||
{
|
||||
intakeEvents: {
|
||||
some: {
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: start, lte: end },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ OR: [{ endsOn: null }, { endsOn: { gte: start } }] },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
schedules: {
|
||||
select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true },
|
||||
@@ -205,9 +232,12 @@ export async function collectDoctorReportData(
|
||||
},
|
||||
},
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
doseChanges: {
|
||||
where: { effectiveFrom: { lte: end } },
|
||||
orderBy: { effectiveFrom: "asc" },
|
||||
},
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
where: { takenAt: { not: null, lte: end } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 1,
|
||||
select: { takenAt: true, injectionSite: true },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -398,6 +398,56 @@ describe("VO2 max", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mappers in the user's zone", () => {
|
||||
it("reads a weigh-in's logged wall clock in the user's zone", () => {
|
||||
const entry = {
|
||||
weight: [
|
||||
{ date: "2026-05-10", time: "07:30:00", weight: 81.4, logId: 7 },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
mapWeight(entry, "Pacific/Auckland")[0]!.measuredAt.toISOString(),
|
||||
).toBe("2026-05-09T19:30:00.000Z");
|
||||
expect(
|
||||
mapWeight(entry, "America/New_York")[0]!.measuredAt.toISOString(),
|
||||
).toBe("2026-05-10T11:30:00.000Z");
|
||||
// The externalId does not depend on the zone.
|
||||
expect(mapWeight(entry, "Pacific/Auckland")[0]!.fieldTag).toBe("7:weight");
|
||||
});
|
||||
|
||||
it("keeps a logId-less weigh-in's externalId as earlier versions spelled it", () => {
|
||||
const out = mapWeight(
|
||||
{ weight: [{ date: "2026-05-10", time: "07:30:00", weight: 81.4 }] },
|
||||
"Pacific/Auckland",
|
||||
);
|
||||
expect(out[0]!.fieldTag).toBe("2026-05-10T07:30:00.000Z:weight");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Pacific/Tongatapu", "2026-05-09T23:00:00.000Z"],
|
||||
["Pacific/Auckland", "2026-05-10T00:00:00.000Z"],
|
||||
["America/Santiago", "2026-05-10T16:00:00.000Z"],
|
||||
["America/New_York", "2026-05-10T16:00:00.000Z"],
|
||||
["Europe/Berlin", "2026-05-10T10:00:00.000Z"],
|
||||
])(
|
||||
"anchors a daily total at local noon in %s, on the same day there",
|
||||
(tz, iso) => {
|
||||
const [steps] = mapSteps(
|
||||
{ "activities-steps": [{ dateTime: "2026-05-10", value: "8421" }] },
|
||||
tz,
|
||||
);
|
||||
expect(steps!.measuredAt.toISOString()).toBe(iso);
|
||||
expect(steps!.fieldTag).toBe("steps:2026-05-10");
|
||||
const [spo2] = mapOxygenSaturation(
|
||||
[{ dateTime: "2026-05-10", value: { avg: 97 } }],
|
||||
tz,
|
||||
);
|
||||
expect(spo2!.measuredAt.toISOString()).toBe(iso);
|
||||
expect(spo2!.fieldTag).toBe("2026-05-10:spo2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("activity mappers (cumulative daily)", () => {
|
||||
it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => {
|
||||
const out = mapSteps({
|
||||
|
||||
@@ -288,7 +288,12 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
|
||||
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
|
||||
// A re-fetched daily summary: the live row already exists.
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ id: "m-live", type: "WEIGHT", externalId: "stats:weight:2026-05-10" },
|
||||
{
|
||||
id: "m-live",
|
||||
type: "WEIGHT",
|
||||
externalId: "stats:weight:2026-05-10",
|
||||
measuredAt,
|
||||
},
|
||||
]);
|
||||
|
||||
const { imported } = await upsertFitbitMeasurements("user1", [
|
||||
@@ -319,7 +324,12 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
|
||||
// probe returns it; planning an insert instead would be dropped silently
|
||||
// by `skipDuplicates` and wedge the key forever.
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ id: "m-dead", type: "WEIGHT", externalId: "stats:weight:2026-05-10" },
|
||||
{
|
||||
id: "m-dead",
|
||||
type: "WEIGHT",
|
||||
externalId: "stats:weight:2026-05-10",
|
||||
measuredAt,
|
||||
},
|
||||
]);
|
||||
|
||||
const { imported } = await upsertFitbitMeasurements("user1", [
|
||||
@@ -342,11 +352,52 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
|
||||
expect(updateArg.data.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("moves a daily row to its new anchor and refolds the day it left", async () => {
|
||||
// A step total written at the old noon-UTC anchor, re-fetched for a user
|
||||
// in Tonga, whose local noon is 23:00 UTC the day before.
|
||||
const legacy = new Date("2026-05-10T12:00:00.000Z");
|
||||
const localNoon = new Date("2026-05-09T23:00:00.000Z");
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "m-steps",
|
||||
type: "ACTIVITY_STEPS",
|
||||
externalId: "stats:steps:2026-05-10",
|
||||
value: 8421,
|
||||
unit: "steps",
|
||||
measuredAt: legacy,
|
||||
sleepStage: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { touched } = await upsertFitbitMeasurements("user1", [
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8421,
|
||||
unit: "steps",
|
||||
measuredAt: localNoon,
|
||||
externalId: "stats:steps:2026-05-10",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(prismaMock.measurement.createManyAndReturn).not.toHaveBeenCalled();
|
||||
expect(prismaMock.measurement.update).toHaveBeenCalledTimes(1);
|
||||
const updateArg = prismaMock.measurement.update.mock.calls[0]![0] as {
|
||||
data: { measuredAt: Date };
|
||||
};
|
||||
expect(updateArg.data.measuredAt).toEqual(localNoon);
|
||||
expect(touched.map((t) => t.measuredAt.toISOString())).toEqual(
|
||||
expect.arrayContaining([localNoon.toISOString(), legacy.toISOString()]),
|
||||
);
|
||||
});
|
||||
|
||||
it("splits a mixed batch: matched-live → update, unmatched → createMany", async () => {
|
||||
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ id: "m-live", type: "WEIGHT", externalId: "live-key" },
|
||||
]);
|
||||
prismaMock.measurement.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "m-live", type: "WEIGHT", externalId: "live-key", measuredAt },
|
||||
])
|
||||
.mockResolvedValueOnce([]); // natural-key rescue: no twin
|
||||
|
||||
const { imported } = await upsertFitbitMeasurements("user1", [
|
||||
{
|
||||
@@ -438,7 +489,7 @@ describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => {
|
||||
// fresh → create branch. Second findMany is the natural-key rescue probe.
|
||||
prismaMock.measurement.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "m-live", type: "WEIGHT", externalId: "live-key" },
|
||||
{ id: "m-live", type: "WEIGHT", externalId: "live-key", measuredAt },
|
||||
])
|
||||
.mockResolvedValueOnce([]); // natural-key rescue: no twin
|
||||
prismaMock.measurement.createManyAndReturn.mockRejectedValueOnce(
|
||||
@@ -478,8 +529,8 @@ describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => {
|
||||
// throws; the loop must continue to the second, and the create still runs.
|
||||
prismaMock.measurement.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "m-live-1", type: "WEIGHT", externalId: "live-1" },
|
||||
{ id: "m-live-2", type: "WEIGHT", externalId: "live-2" },
|
||||
{ id: "m-live-1", type: "WEIGHT", externalId: "live-1", measuredAt },
|
||||
{ id: "m-live-2", type: "WEIGHT", externalId: "live-2", measuredAt },
|
||||
])
|
||||
.mockResolvedValueOnce([]); // natural-key rescue: no twin
|
||||
prismaMock.measurement.update
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user