Merge pull request #1167 from MBombeck/fix/v1403-polish

chore(release): v1.40.3
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-05 13:37:36 +02:00
commit 854cd31e18
60 files changed
+2208 -177

No files matched your search

+36
View File
@@ -1,5 +1,41 @@
# Changelog
## [1.40.3] — 2026-10-05
Small fixes to the Today overview and the numbers behind it.
### Added
- **`signalLine` on the daily digest.** The muted line under the lead is now
decided on the server: when the lead already talks about the top signal's
metric, only the delta remains, and the line is null when nothing is left.
Every client shows the same line.
- **`basis` on health-status deviations.** `day` for the usual whole-day
range, `sameHours` for the usual range at this time of day.
### Fixed
- The Today lead is shown as whole sentences. A long sentence is no longer
cut mid-word; the headline or the deterministic sentence takes its place,
and only as a last resort is text shortened, at a word boundary.
- Chart trend lines, legends and tooltips name every metric in the reader's
language instead of showing its internal name (resting heart rate among
them).
- A personal usual range never collapses to a single value ("61–61 bpm"):
it keeps at least the measurement's own day-to-day spread, so the next
ordinary reading is not counted as outside it.
- Glucose today is compared with the same hours of earlier days, up to the
time of today's latest reading. A fasting morning no longer reads as below
the range of whole days, and the range named is the usual one for this
time of day.
- Encrypting stored text in place or rotating the encryption key no longer
makes old Coach conversations look recently active, and the Coach memory
summary no longer moves a conversation either. Measurements, mood entries
and every other encrypted record keep their sync position through the
same kind of rewrite.
- Conversations an earlier release made look recently active move back to
their last message (migration 0375).
## [1.40.2] — 2026-10-05
A calmer Coach with conversations in a side panel and settings in place; the
+1 -1
View File
@@ -24,7 +24,7 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
<img width="2434" height="1247" alt="HealthLog dashboard in dark theme: the Today overview with a lead sentence, the health score ring, Today facts and worth-a-look cards, above metric tiles with 7-day trends" src="docs/assets/dashboard-hero.jpeg" />
<p align="center">
<img src="docs/assets/Insights.png"
alt="Insights"
+33 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.40.2
version: 1.40.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -60433,6 +60433,14 @@ components:
enum:
- above
- below
basis:
type: string
enum:
- day
- sameHours
description: "What `low`/`high` describe: the usual whole day (`day`), or the usual range for this time of day
(`sameHours`), used for a day still in progress whose mean moves with the hour (glucose). Additive
since v1.40.3."
required:
- type
- value
@@ -60440,6 +60448,7 @@ components:
- low
- high
- direction
- basis
additionalProperties: false
shifts:
type: array
@@ -72562,6 +72571,28 @@ components:
are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its
personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the
deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40."
signalLine:
anyOf:
- type: object
properties:
headline:
anyOf:
- type: string
- type: "null"
description: The top signal's headline, or null when the lead already talks about its metric.
delta:
anyOf:
- type: string
- type: "null"
description: The top signal's delta, when it has one.
required:
- headline
- delta
additionalProperties: false
- type: "null"
description: "The muted line under an AI lead, decided on the server so every client shows the same one: the top signal
minus what the lead already says. Null under a deterministic lead, without a top signal, or when the lead
already says everything the signal would. Additive since v1.40.3."
today:
maxItems: 5
type: array
@@ -72652,6 +72683,7 @@ components:
- topSignal
- briefingLead
- lead
- signalLine
- today
- restMode
- line
+7
View File
@@ -51,6 +51,7 @@ const BASE: DailyDigest = {
steadyWeeks: 4,
},
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
today: [],
@@ -115,6 +116,12 @@ const STATES: Record<string, DailyDigest> = {
text: "Your latest blood pressure is sitting in the optimal band.",
source: "briefing",
},
// The lead already talks about blood pressure, so the server keeps only
// the delta under it.
signalLine: {
headline: null,
delta: "↓ ~10 mmHg systolic vs the start of the window",
},
today: FACTS,
worthALook: [
{
+2
View File
@@ -30,6 +30,8 @@ const NARRATIVE_DIGEST: DailyDigest = {
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
// The lead talks about blood pressure, so the server keeps only the delta.
signalLine: { headline: null, delta: "−12 mmHg vs. 30-Tage-Mittel" },
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" },
today: [],
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.",
"shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.",
"rangeContext": "Aktuell {value}, sonst {low}–{high}.",
"rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.",
"shiftContext": "Etwa {before} davor, {after} seitdem."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Seit {count} Wochen stabil",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} bis {high} {unit}",
"rangeAtThisTime": "{range} für diese Tageszeit",
"vital": {
"RESTING_HEART_RATE": "Ruhepuls",
"HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} has shifted up from your recent normal.",
"shiftDown": "{vital} has shifted down from your recent normal.",
"rangeContext": "Now {value}, usually {low}–{high}.",
"rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.",
"shiftContext": "Around {before} before, {after} since."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Steady for {count} weeks",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} to {high} {unit}",
"rangeAtThisTime": "{range} for this time of day",
"vital": {
"RESTING_HEART_RATE": "Resting heart rate",
"HEART_RATE_VARIABILITY": "Heart-rate variability",
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} ha subido respecto a tu normal reciente.",
"shiftDown": "{vital} ha bajado respecto a tu normal reciente.",
"rangeContext": "Ahora {value}, normalmente {low}–{high}.",
"rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.",
"shiftContext": "Cerca de {before} antes, {after} desde entonces."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Estable desde hace {count} semanas",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} a {high} {unit}",
"rangeAtThisTime": "{range} para esta hora del día",
"vital": {
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
"HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} a augmenté par rapport à votre normale récente.",
"shiftDown": "{vital} a diminué par rapport à votre normale récente.",
"rangeContext": "Actuellement {value}, habituellement {low}–{high}.",
"rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.",
"shiftContext": "Environ {before} avant, {after} depuis."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Stable depuis {count} semaines",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} à {high} {unit}",
"rangeAtThisTime": "{range} pour ce moment de la journée",
"vital": {
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
"HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.",
"shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.",
"rangeContext": "Ora {value}, di solito {low}–{high}.",
"rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.",
"shiftContext": "Circa {before} prima, {after} da allora."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Stabile da {count} settimane",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "da {low} a {high} {unit}",
"rangeAtThisTime": "{range} per quest'ora del giorno",
"vital": {
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
"HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} — 최근 평소 수준보다 올라갔어요.",
"shiftDown": "{vital} — 최근 평소 수준보다 내려갔어요.",
"rangeContext": "지금 {value}, 평소 {low}–{high}.",
"rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.",
"shiftContext": "이전엔 약 {before}, 이후엔 {after}."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "{count}주째 안정적",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low}~{high} {unit}",
"rangeAtThisTime": "이 시간대 기준 {range}",
"vital": {
"RESTING_HEART_RATE": "안정 시 심박수",
"HEART_RATE_VARIABILITY": "심박 변이도",
+2
View File
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.",
"shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.",
"rangeContext": "Teraz {value}, zwykle {low}–{high}.",
"rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.",
"shiftContext": "Około {before} wcześniej, {after} od tego czasu."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Stabilnie od {count} tygodni",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} do {high} {unit}",
"rangeAtThisTime": "{range} o tej porze dnia",
"vital": {
"RESTING_HEART_RATE": "Tętno spoczynkowe",
"HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.40.2",
"version": "1.40.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,26 @@
-- Move a Coach conversation's updated_at back to its newest message.
--
-- The free-text encryption backfill (v1.39.3) and the memory-summary refresh
-- rewrote conversation rows through a Prisma update, which stamped updated_at
-- with the run time. The Coach panel orders and groups by that column, so
-- conversations nobody had touched in months showed up under "Today".
--
-- A turn stamps updated_at in the same transaction that creates its message,
-- so the newest message's created_at is the conversation's real last
-- activity. Only rows more than a minute later than that move back; a rename
-- or a document attached shortly after the last turn keeps its stamp, and a
-- conversation without messages is left alone. Running it again changes
-- nothing: a repaired row is no longer later than its newest message.
--
-- One set-based statement. The per-conversation maximum is read from
-- "coach_messages_conversation_id_created_at_idx" (conversation_id,
-- created_at), so it does not sort the message table.
UPDATE "coach_conversations" AS c
SET "updated_at" = m."last_at"
FROM (
SELECT "conversation_id", MAX("created_at") AS "last_at"
FROM "coach_messages"
GROUP BY "conversation_id"
) AS m
WHERE m."conversation_id" = c."id"
AND c."updated_at" > m."last_at" + INTERVAL '1 minute';
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.40.2";
/* @sw-version-fallback */ "v1.40.3";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+5 -2
View File
@@ -52,6 +52,7 @@ interface RowSnapshot {
id: string;
tags: string | null;
note: string | null;
updatedAt: Date;
}
async function main(): Promise<void> {
@@ -74,7 +75,7 @@ async function main(): Promise<void> {
tags: { not: null },
note: null,
},
select: { id: true, tags: true, note: true },
select: { id: true, tags: true, note: true, updatedAt: true },
});
console.log(`[backfill-note] scanned ${candidates.length} candidate rows`);
@@ -87,9 +88,11 @@ async function main(): Promise<void> {
updated += 1;
if (!dryRun) {
// The entry did not change for its reader; it keeps its `updatedAt`
// (the sync cursor) rather than taking the script's run time.
await prisma.moodEntry.update({
where: { id: row.id },
data: { note, tags: newTags },
data: { note, tags: newTags, updatedAt: row.updatedAt },
});
}
}
+44 -7
View File
@@ -39,6 +39,8 @@ import {
import {
ENCRYPTED_COLUMNS,
encryptedColumnKey,
preserveUpdatedAt,
UPDATED_AT_MODELS,
type EncryptedColumn,
} from "@/lib/crypto/encrypted-columns";
import { tokeniseAndHash } from "@/lib/documents/content-index";
@@ -106,6 +108,15 @@ async function rotateRegistryColumn(
};
}
/** True for a Prisma "record to update not found" (P2025). */
function isRowGone(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
(err as { code?: unknown }).code === "P2025"
);
}
function shouldRotate(value: string | null): boolean {
if (!value) return false;
const id = extractKeyId(value);
@@ -122,8 +133,8 @@ interface PagedDelegate<V> {
where?: { id: { gt: string } };
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, V>;
where: { id: string; updatedAt?: Date };
data: Record<string, V | Date>;
}) => Promise<unknown>;
}
@@ -139,11 +150,16 @@ const PAGE_SIZE = 5_000;
async function* pagedRows(
delegate: PagedDelegate<unknown>,
field: string,
table: string,
): AsyncGenerator<Record<string, unknown>> {
let cursor: string | null = null;
for (;;) {
const rows = await delegate.findMany({
select: { id: true, [field]: true },
select: {
id: true,
[field]: true,
...(UPDATED_AT_MODELS.has(table) ? { updatedAt: true } : {}),
},
orderBy: { id: "asc" },
take: PAGE_SIZE,
// `id > last`, not Prisma's `cursor` + `skip`: the cursor form looks
@@ -178,6 +194,7 @@ async function rotateStringColumn(
for await (const row of pagedRows(
delegate as PagedDelegate<unknown>,
field,
table,
)) {
const v = row[field] as string | null;
// `scanned` counts rows that hold ciphertext, as the in-app rotation does.
@@ -187,9 +204,16 @@ async function rotateStringColumn(
const id = row.id as string;
try {
const re = encrypt(decrypt(v));
await delegate.update({ where: { id }, data: { [field]: re } });
// Re-sealing is not an edit: the row keeps its own updatedAt.
const keepUpdatedAt = preserveUpdatedAt(table, row.updatedAt);
await delegate.update({
where: { id, ...keepUpdatedAt },
data: { [field]: re, ...keepUpdatedAt },
});
result.rotated++;
} catch (err) {
// Edited or deleted since the read: the next run picks it up.
if (isRowGone(err)) continue;
result.errors++;
console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
}
@@ -218,6 +242,7 @@ async function rotateBytesColumn(
for await (const row of pagedRows(
delegate as PagedDelegate<unknown>,
field,
table,
)) {
const buf = row[field] as Uint8Array | null;
if (!buf || buf.byteLength === 0) continue;
@@ -230,9 +255,14 @@ async function rotateBytesColumn(
const encoded = Buffer.from(rotated, "utf8");
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
next.set(encoded);
await delegate.update({ where: { id }, data: { [field]: next } });
const keepUpdatedAt = preserveUpdatedAt(table, row.updatedAt);
await delegate.update({
where: { id, ...keepUpdatedAt },
data: { [field]: next, ...keepUpdatedAt },
});
result.rotated++;
} catch (err) {
if (isRowGone(err)) continue;
result.errors++;
console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
}
@@ -898,7 +928,12 @@ async function main() {
};
for (;;) {
const rows = await prisma.documentContentIndex.findMany({
select: { id: true, textEncrypted: true, verbatimTextEncrypted: true },
select: {
id: true,
textEncrypted: true,
verbatimTextEncrypted: true,
updatedAt: true,
},
orderBy: { id: "asc" },
take: 100,
...(cursor ? { where: { id: { gt: cursor } } } : {}),
@@ -924,8 +959,10 @@ async function main() {
? reEncryptBytes(Buffer.from(verbatimBuf!).toString("utf8"))
: undefined;
await prisma.documentContentIndex.update({
where: { id: row.id },
// Re-sealing is not an edit: the row keeps its own updatedAt.
where: { id: row.id, updatedAt: row.updatedAt },
data: {
updatedAt: row.updatedAt,
textEncrypted: nextBytes,
searchTokens,
...(verbatimNext ? { verbatimTextEncrypted: verbatimNext } : {}),
@@ -0,0 +1,227 @@
/**
* A write that is not activity keeps the row's `updatedAt`.
*
* Prisma's `@updatedAt` stamps "now" on every `update` / `updateMany` /
* `upsert` that does not name the column. That is right for an edit and
* wrong for a one-shot rewrite of how a value is stored: the title
* encryption backfill bumped every Coach conversation it touched, and the
* Coach panel, which groups and orders conversations by `updatedAt`, then
* showed months-old threads under "Today". The same column is the sync
* cursor for measurements and mood entries, so a storage-only rewrite there
* made every client pull rows that had not changed.
*
* Two freezes:
*
* 1. Every Prisma write in a backfill (`lib/jobs/*-backfill.ts`,
* `scripts/backfill-*.ts`) on a model with an `@updatedAt` column names
* `updatedAt` in the call (carrying the row's own value forward), or is
* listed below with the reason the stamp is wanted.
* 2. Every `coachConversation` write anywhere names `updatedAt`, or is
* listed as something the person did in the conversation. Background
* bookkeeping (the memory summary) writes through raw SQL, which Prisma
* does not stamp.
*
* 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, readdirSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const ROOT = process.cwd();
const SRC = join(ROOT, "src");
/** Models (as Prisma client accessors) that carry an `@updatedAt` column. */
function modelsWithUpdatedAt(): Set<string> {
const schema = readFileSync(join(ROOT, "prisma/schema.prisma"), "utf8");
const out = new Set<string>();
for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
if (/@updatedAt\b/.test(m[2])) {
out.add(m[1][0].toLowerCase() + m[1].slice(1));
}
}
return out;
}
const WRITE_RE = /\.\s*([a-zA-Z]+)\s*\.\s*(update|updateMany|upsert)\s*\(/g;
/** The argument text of the call whose `(` sits at `open`. */
function callArgs(source: string, open: number): string {
let depth = 0;
for (let i = open; i < source.length; i++) {
const c = source[i];
if (c === "(") depth++;
else if (c === ")") {
depth--;
if (depth === 0) return source.slice(open, i + 1);
}
}
return source.slice(open);
}
interface Write {
file: string;
model: string;
namesUpdatedAt: boolean;
}
function writesIn(file: string, source: string): Write[] {
const out: Write[] = [];
for (const m of source.matchAll(WRITE_RE)) {
const open = (m.index ?? 0) + m[0].length - 1;
out.push({
file,
model: m[1],
namesUpdatedAt: /\bupdatedAt\s*:/.test(callArgs(source, open)),
});
}
return out;
}
function backfillFiles(): string[] {
const jobs = readdirSync(join(SRC, "lib/jobs"))
.filter((f) => /-backfill\.ts$/.test(f))
.map((f) => `src/lib/jobs/${f}`);
const scripts = readdirSync(join(ROOT, "scripts"))
.filter((f) => /^backfill-.*\.ts$/.test(f))
.map((f) => `scripts/${f}`);
return [...jobs, ...scripts].sort();
}
/**
* Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`.
*/
const BACKFILL_STAMPS: Record<string, string> = {
"src/lib/jobs/fitbit-backfill.ts > fitbitConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/google-health-backfill.ts > googleHealthConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/whoop-backfill.ts > whoopConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/sleep-timeline-backfill.ts > whoopConnection":
"marks the connection's one-shot as done; not a listed record",
"src/lib/jobs/sleep-timeline-backfill.ts > withingsConnection":
"marks the connection's one-shot as done; not a listed record",
"src/lib/jobs/strava-backfill.ts > user":
"stamps the account's backfill-completed marker",
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
"the index row is rebuilt content; its updatedAt is the rebuild time",
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
"shared catalogue row created or refreshed by the backfill",
"src/lib/jobs/lab-biomarker-backfill.ts > labResult":
"bulk link to a catalogue id; no lab surface orders by updatedAt and a bulk write cannot carry each row's own value",
"src/lib/jobs/free-text-encryption-backfill.ts > measurementReminder":
"bulk clear of a retired readable copy; reminders are listed by schedule, not by updatedAt",
};
/**
* `coachConversation` writes outside backfills that stamp `updatedAt`
* because the person did something in the conversation, keyed by file.
*/
const COACH_ACTIVITY: Record<string, string> = {
"src/lib/ai/coach/persistence.ts":
"a turn sets updatedAt to now explicitly; a rename and a document attach are the person acting on the thread",
};
describe("backfills keep updatedAt", () => {
const stamped = modelsWithUpdatedAt();
const files = backfillFiles();
const writes = files.flatMap((f) =>
writesIn(f, readFileSync(join(ROOT, f), "utf8")),
);
it("reads the schema and the backfills at all", () => {
// An empty match set would agree with any allowlist.
expect(stamped.size).toBeGreaterThanOrEqual(40);
expect(stamped.has("coachConversation")).toBe(true);
expect(files.length).toBeGreaterThanOrEqual(10);
expect(writes.length).toBeGreaterThanOrEqual(15);
});
it("carries updatedAt through every storage-only rewrite", () => {
const offenders = writes
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
.map((w) => `${w.file} > ${w.model}`)
.filter((key) => !(key in BACKFILL_STAMPS));
expect(
[...new Set(offenders)],
"a backfill write bumps updatedAt: carry the row's own value (`updatedAt: fresh.updatedAt`), or list it with the reason the stamp is wanted",
).toEqual([]);
});
it("has no stale entries", () => {
const seen = new Set(
writes
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
.map((w) => `${w.file} > ${w.model}`),
);
expect(Object.keys(BACKFILL_STAMPS).filter((k) => !seen.has(k))).toEqual(
[],
);
});
});
describe("coach conversations move only on activity", () => {
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.map((p) => `src/${p}`);
const writes = [
...files.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
...backfillFiles()
.filter((f) => f.startsWith("scripts/"))
.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
].filter((w) => w.model === "coachConversation");
it("finds the conversation writes at all", () => {
expect(writes.length).toBeGreaterThanOrEqual(3);
});
it("names updatedAt in every write that is not the person's own action", () => {
const offenders = writes
.filter((w) => !w.namesUpdatedAt && !(w.file in COACH_ACTIVITY))
.map((w) => w.file);
expect(
[...new Set(offenders)],
"a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL)",
).toEqual([]);
});
});
/**
* Key rotation re-seals values in place across every encrypted model, through
* generic delegates the model-named matcher above cannot see. Every write it
* makes names `updatedAt`, carrying the row's own value where the model has
* one; re-sealing under a new key is not an edit.
*/
describe("key rotation keeps updatedAt", () => {
const ROTATION_FILES = [
"src/lib/crypto/encryption-corpus.ts",
"scripts/rotate-encryption-key.ts",
];
const ANY_WRITE_RE = /\.\s*(update|updateMany|upsert)\s*\(/g;
const calls = ROTATION_FILES.flatMap((file) => {
const source = readFileSync(join(ROOT, file), "utf8");
return [...source.matchAll(ANY_WRITE_RE)].map((m) => ({
file,
args: callArgs(source, (m.index ?? 0) + m[0].length - 1),
}));
});
it("finds the rotation writes at all", () => {
expect(calls.length).toBeGreaterThanOrEqual(4);
});
it("names updatedAt in every rotation write", () => {
const offenders = calls
.filter((c) => !/updatedAt/i.test(c.args))
.map((c) => `${c.file}: ${c.args.slice(0, 80).replace(/\s+/g, " ")}`);
expect(
offenders,
"a rotation write must carry the row's own updatedAt",
).toEqual([]);
});
});
@@ -70,6 +70,7 @@ const DIGEST: DailyDigest = {
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: null,
signalLine: null,
briefingLead: "Blood pressure is holding steady.",
lead: { text: "Blood pressure is holding steady.", source: "briefing" },
today: [],
@@ -354,6 +354,7 @@ const DIGEST: DailyDigest = {
sleepPending: false,
score: null,
topSignal: null,
signalLine: null,
briefingLead: "A steady week so far.",
lead: { text: "A steady week so far.", source: "briefing" },
today: [],
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import de from "../../../../messages/de.json";
import en from "../../../../messages/en.json";
import es from "../../../../messages/es.json";
import fr from "../../../../messages/fr.json";
import it_ from "../../../../messages/it.json";
import ko from "../../../../messages/ko.json";
import pl from "../../../../messages/pl.json";
/**
* The chart names a series by its localised metric label, never by the enum.
*
* The label map in the chart covered eleven types and fell back to the raw
* type for every other one, so a resting-pulse chart with its trend overlays
* on read "RESTING_HEART_RATE" in the trend line, the legend, the tooltip and
* the spoken summary. Every overlay series takes its name from the same
* helper, so the summary and the trend line stand in for all of them here.
*/
const MESSAGES = { de, en, es, fr, it: it_, ko, pl } as const;
function buildData(): unknown[] {
const out: Array<{
date: string;
timestamp: number;
RESTING_HEART_RATE: number;
}> = [];
const base = Date.now() - 12 * 3_600_000;
for (let i = 0; i < 28; i++) {
out.push({
date: `p${i}`,
timestamp: base - (27 - i) * 86_400_000,
RESTING_HEART_RATE: 58 + (i % 5),
});
}
return out;
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
}));
vi.mock("@/hooks/use-chart-overlay-prefs", () => ({
useChartOverlayPrefs: () => ({
prefs: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "none",
},
setPrefs: () => undefined,
isSaving: false,
}),
}));
describe("<HealthChart> — series names", () => {
beforeEach(() => {
vi.resetModules();
});
it.each(Object.keys(MESSAGES) as Array<keyof typeof MESSAGES>)(
"names the resting-pulse series in %s, overlays on",
async (locale) => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const messages = MESSAGES[locale];
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale} initialMessages={messages}>
<HealthChart
types={["RESTING_HEART_RATE"]}
title="Resting pulse"
unit="bpm"
chartKey="pulse"
/>
</I18nProvider>,
);
const label = (
messages as unknown as { measurements: Record<string, string> }
).measurements.typeRestingHeartRate;
expect(label).toBeTruthy();
const aria = html.match(/role="img"[^>]*aria-label="([^"]*)"/)?.[1];
expect(aria).toBeTruthy();
expect(aria).not.toContain("RESTING_HEART_RATE");
const visible = html.replace(/<[^>]+>/g, " ");
expect(visible).not.toContain("RESTING_HEART_RATE");
expect(visible).toContain(label);
vi.doUnmock("@tanstack/react-query");
},
);
});
describe("measurementLabelKey", () => {
it("matches the canonical label map for every type outside the chart's own labels", async () => {
const { MEASUREMENT_TYPE_LABEL_KEYS } =
await import("@/lib/measurements/type-label-keys");
const { measurementLabelKey } = await import("../health-chart");
// The four types whose key breaks the convention carry a chart label.
const chartLabelled = new Set([
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
]);
const mismatched = Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)
.filter(([type]) => !chartLabelled.has(type))
.filter(([type, key]) => measurementLabelKey(type) !== key);
expect(mismatched).toEqual([]);
expect(Object.keys(MEASUREMENT_TYPE_LABEL_KEYS).length).toBeGreaterThan(50);
});
});
+25 -1
View File
@@ -381,6 +381,27 @@ const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
OXYGEN_SATURATION: "charts.spo2",
};
/**
* The measurement label key for a type outside the chart's short labels:
* `RESTING_HEART_RATE` → `measurements.typeRestingHeartRate`. Derived rather
* than imported, because the full label map would ride into every route that
* draws a chart; `health-chart-type-label.test.tsx` pins that the derivation
* matches `MEASUREMENT_TYPE_LABEL_KEYS` for every type it is used for.
*/
export function measurementLabelKey(type: string): string {
return `measurements.type${type
.toLowerCase()
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("")}`;
}
/**
* The name a series carries in the trend line, the legend, the tooltip and
* the spoken summary. The chart's own short labels come first; every other
* type takes its measurement label, so no overlay ever shows the raw enum
* (a resting-pulse trend read "RESTING_HEART_RATE").
*/
function getTypeLabel(
type: string,
valueMode: "raw" | "bmi",
@@ -390,7 +411,10 @@ function getTypeLabel(
return "BMI";
}
const key = BASE_TYPE_LABEL_KEYS[type];
return key ? t(key) : type;
if (key) return t(key);
const measurementKey = measurementLabelKey(type);
const label = t(measurementKey);
return label === measurementKey ? type : label;
}
/**
@@ -41,6 +41,7 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
sleepPending: false,
score: { value: 82, band: "green", delta: 3 },
topSignal: null,
signalLine: null,
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
today: [],
@@ -71,6 +71,12 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
nudge: "Take it again after a calm five minutes.",
delta: "+6 mmHg vs your 30-day average",
},
// Resolved on the server: the lead says nothing about blood pressure, so
// the whole signal rides under it.
signalLine: {
headline: "Blood pressure a touch high this morning",
delta: "+6 mmHg vs your 30-day average",
},
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
today: [],
@@ -209,6 +215,7 @@ describe("<TodayHero>", () => {
lead: null,
briefingLead: null,
topSignal: null,
signalLine: null,
reactionLine: "Score 82 today.",
})}
/>,
@@ -233,6 +240,7 @@ describe("<TodayHero>", () => {
nudge: "",
delta: null,
},
signalLine: null,
})}
/>,
);
@@ -270,6 +278,7 @@ describe("<TodayHero>", () => {
digest={digest({
score: null,
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
@@ -289,6 +298,7 @@ describe("<TodayHero>", () => {
digest={digest({
score: null,
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
@@ -340,6 +350,10 @@ describe("<TodayHero>", () => {
text: "Blood pressure a touch high this morning.",
source: "briefing",
},
signalLine: {
headline: null,
delta: "+6 mmHg vs your 30-day average",
},
})}
/>,
);
@@ -355,6 +369,7 @@ describe("<TodayHero>", () => {
<TodayHero
digest={digest({
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
reactionLine: null,
@@ -378,6 +393,7 @@ describe("<TodayHero>", () => {
<TodayHero
digest={digest({
topSignal: null,
signalLine: null,
worthALook: [],
})}
/>,
@@ -443,6 +459,7 @@ describe("<TodayHero>", () => {
digest={digest({
score: null,
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
worthALook: [],
@@ -596,6 +613,7 @@ describe("<TodayHero> Today overview", () => {
digest={digest({
score: null,
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
worthALook: [],
@@ -615,6 +633,7 @@ describe("<TodayHero> Today overview", () => {
text: "All 4 of your latest vitals sit inside their usual range.",
source: "signal",
},
signalLine: null,
})}
/>,
);
+6 -26
View File
@@ -74,20 +74,6 @@ function formatDelta(
return t("daily.today.deltaVsBaseline", { delta: signed });
}
/** Lowercase letters and digits only, for a wording-insensitive compare. */
function normalise(text: string): string {
return text
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
/** Whether `lead` already says what `headline` says. */
function restates(lead: string, headline: string): boolean {
const h = normalise(headline);
return h.length > 0 && normalise(lead).includes(h);
}
export function TodayHero({
digest,
renderFilteredAllClear = false,
@@ -148,18 +134,12 @@ export function TodayHero({
digest.lead === undefined
? (digest.reactionLine ?? digest.briefingLead ?? null)
: (digest.lead?.text ?? null);
const topSignal = digest.topSignal;
// The briefing's top signal rides under an AI lead as its supporting line,
// and only with what the lead does not already say: a headline the lead
// restates is dropped and its delta, when there is one, stands alone.
const signalLine =
topSignal && lead && digest.lead?.source !== "signal"
? restates(lead, topSignal.headline)
? topSignal.delta
? { headline: null, delta: topSignal.delta }
: null
: { headline: topSignal.headline, delta: topSignal.delta }
: null;
// The line under the lead is decided on the server (`digest.signalLine`):
// the briefing's top signal minus whatever the lead already says, so the
// headline goes when the lead talks about its metric and the delta stands
// alone. A digest the service worker cached before the field existed simply
// shows no line.
const signalLine = lead ? (digest.signalLine ?? null) : null;
// A score-only digest with nothing else to say has no narrative content for
// the leading column. Keeping the full md ring in that two-column shell left
// a blank column beside a 168 px dial and pushed the all-clear read below
+10 -5
View File
@@ -109,11 +109,16 @@ export function HealthStatusCard({
)}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.healthStatus.rangeContext", {
value: metricValue(d.type, d.value),
low: metricValue(d.type, d.low),
high: metricValue(d.type, d.high),
})}
{t(
d.basis === "sameHours"
? "insights.healthStatus.rangeContextTimeOfDay"
: "insights.healthStatus.rangeContext",
{
value: metricValue(d.type, d.value),
low: metricValue(d.type, d.low),
high: metricValue(d.type, d.high),
},
)}
</p>
</div>
</div>
@@ -53,7 +53,8 @@ function makePrisma(conversation: unknown) {
update,
findFirst,
client: {
coachConversation: { findFirst, update },
coachConversation: { findFirst },
$executeRaw: update,
} as never,
};
}
@@ -161,7 +162,7 @@ describe("refreshConversationSummary", () => {
}
});
it("(e) success path encrypts and persists summaryEncrypted + turn count + updatedAt", async () => {
it("(e) success path persists the summary and turn count, and leaves updatedAt alone", async () => {
const now = new Date("2026-06-04T08:00:00.000Z");
const { client, update } = makePrisma(
makeConversation(30, { summaryTurnCount: 0 }),
@@ -177,12 +178,19 @@ describe("refreshConversationSummary", () => {
expect(result.status).toBe("generated");
expect(encryptToBytes).toHaveBeenCalledWith(okCompletion.content);
expect(update).toHaveBeenCalledTimes(1);
const updateArg = update.mock.calls[0]![0];
expect(updateArg.where).toEqual({ id: "conv-1" });
expect(updateArg.data.summaryEncrypted).toBeDefined();
expect(updateArg.data.summaryUpdatedAt).toBe(now);
const [strings, ...values] = update.mock.calls[0]! as [
TemplateStringsArray,
...unknown[],
];
const sql = strings.join("?");
expect(sql).toContain('UPDATE "coach_conversations"');
expect(sql).toContain('"summary_encrypted"');
// A memory rewrite is not activity: the panel orders by updated_at.
expect(sql).not.toMatch(/"updated_at"/);
expect(values[1]).toBe(now);
// foldHighWater = 30 - 18 = 12.
expect(updateArg.data.summaryTurnCount).toBe(12);
expect(values[2]).toBe(12);
expect(values[3]).toBe("conv-1");
});
});
+11 -9
View File
@@ -56,7 +56,7 @@ type RunCompletion = typeof runStatusCompletion;
export interface RefreshConversationSummaryOptions {
now?: Date;
runCompletion?: RunCompletion;
prisma?: Pick<PrismaClient, "coachConversation">;
prisma?: Pick<PrismaClient, "coachConversation" | "$executeRaw">;
locale?: "de" | "en";
}
@@ -227,14 +227,16 @@ export async function refreshConversationSummary(
// 6. Encrypt + persist. Field-by-field data object (no spread).
const summaryEncrypted = encryptToBytes(text);
await prisma.coachConversation.update({
where: { id: conversationId },
data: {
summaryEncrypted,
summaryUpdatedAt: now,
summaryTurnCount: foldHighWater,
},
});
// Raw SQL on purpose: a Prisma `update` stamps `updatedAt`, and the Coach
// panel orders and groups conversations by it. A memory rewrite is
// bookkeeping, not activity, so it must not move the thread to "Today".
// Every value is a bound parameter.
await prisma.$executeRaw`
UPDATE "coach_conversations"
SET "summary_encrypted" = ${summaryEncrypted},
"summary_updated_at" = ${now},
"summary_turn_count" = ${foldHighWater}
WHERE "id" = ${conversationId}`;
// 7. Counts + ids only — never the summary text.
annotate({
@@ -4,6 +4,7 @@ import { join } from "node:path";
import {
ENCRYPTED_COLUMNS,
encryptedColumnKey,
UPDATED_AT_MODELS,
type EncryptedColumn,
} from "../encrypted-columns";
@@ -224,3 +225,17 @@ describe("encrypted-column registry", () => {
).toEqual([]);
});
});
describe("UPDATED_AT_MODELS", () => {
it("lists exactly the registry models that carry @updatedAt in the schema", () => {
const schema = readFileSync(SCHEMA_PATH, "utf8");
const stamped = new Set<string>();
for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
if (/@updatedAt\b/.test(m[2])) stamped.add(m[1]);
}
const registry = [...new Set(ENCRYPTED_COLUMNS.map((c) => c.model))];
const expected = registry.filter((m) => stamped.has(m)).sort();
expect(expected.length).toBeGreaterThan(20);
expect([...UPDATED_AT_MODELS].sort()).toEqual(expected);
});
});
+65
View File
@@ -527,3 +527,68 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
export function encryptedColumnKey(c: EncryptedColumn): string {
return `${c.model}.${c.field}`;
}
/**
* The registry models that carry a Prisma `@updatedAt` column. Re-sealing a
* value under a new key is not an edit, so the rotation carries each row's
* own `updatedAt` through the write instead of letting Prisma stamp the run
* time: the Coach panel orders conversations by it and it is the sync cursor
* for measurements and mood entries. `encrypted-columns.test.ts` derives the
* set from `schema.prisma` and fails when it drifts.
*/
export const UPDATED_AT_MODELS: ReadonlySet<string> = new Set([
"Allergy",
"Biomarker",
"CoachConversation",
"CoachFact",
"CoachPlan",
"CoachReminder",
"CycleDayLog",
"DocumentContentIndex",
"DocumentSourceConnection",
"DocumentThumbnail",
"EcgRecording",
"Encounter",
"ExtractedFact",
"FamilyHistoryEntry",
"FitbitConnection",
"GoogleHealthConnection",
"IllnessDayLog",
"IllnessEpisode",
"InboundDocument",
"InsightNarrative",
"InsightStatusCache",
"IntegrationStatus",
"LabResult",
"Measurement",
"MedicationCategoryLabel",
"MedicationCourse",
"MedicationInventoryItem",
"MentalHealthAssessment",
"MoodContext",
"MoodEntry",
"NotificationChannel",
"Practitioner",
"SymptomDefinition",
"SymptomEvent",
"User",
"UserHealthProfile",
"VaccinationRecord",
"WhoopConnection",
"WithingsConnection",
]);
/**
* The `where` filter and `data` fields that keep a row's `updatedAt` through
* a storage-only write: match on the value read (a row edited meanwhile is
* left for the next run, as Prisma's P2025) and write it back unchanged.
* Empty for a model without the column.
*/
export function preserveUpdatedAt(
model: string,
updatedAt: unknown,
): { updatedAt?: Date } {
return UPDATED_AT_MODELS.has(model) && updatedAt instanceof Date
? { updatedAt }
: {};
}
+16 -5
View File
@@ -48,6 +48,8 @@ import {
import {
ENCRYPTED_COLUMNS,
type EncryptedColumn,
preserveUpdatedAt,
UPDATED_AT_MODELS,
} from "@/lib/crypto/encrypted-columns";
/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */
@@ -79,7 +81,7 @@ interface ColumnDelegate {
where?: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: Record<string, string>;
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
/** Only ever called for a `disposable` column's unreadable rows. */
@@ -183,6 +185,8 @@ async function walkColumn(
id: string;
value: unknown;
codec: string | null;
/** The row's `updatedAt`, for a model that has one; else undefined. */
updatedAt: unknown;
}) => Promise<void> | void,
): Promise<void> {
const codecField = col.codecField;
@@ -197,6 +201,7 @@ async function walkColumn(
[pk]: true,
[col.field]: true,
...(codecField ? { [codecField]: true } : {}),
...(UPDATED_AT_MODELS.has(col.model) ? { updatedAt: true } : {}),
},
orderBy: { [pk]: "asc" },
take: batchSize,
@@ -214,6 +219,7 @@ async function walkColumn(
id: row[pk] as string,
value,
codec: codecField ? String(row[codecField] ?? "") : (col.codec ?? null),
updatedAt: row.updatedAt,
});
}
cursor = rows[rows.length - 1]![pk] as string;
@@ -467,7 +473,7 @@ export async function rotateColumn(
// interrupted run resumes cleanly on the next invocation. `scanned` counts
// the rows that hold ciphertext, whatever the column.
try {
await walkColumn(delegate, col, async ({ id, value, codec }) => {
await walkColumn(delegate, col, async ({ id, value, codec, updatedAt }) => {
if (shouldStop()) throw new RotationStopped();
result.scanned += 1;
if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return;
@@ -481,14 +487,19 @@ export async function rotateColumn(
encrypt(decrypt(toCiphertext(value, col.kind)!)),
col.kind,
);
// Re-sealing is not an edit: the row keeps its own `updatedAt`, and
// the write matches on it, so a row edited since the read is left for
// the next run rather than stamped with the run time.
const keepUpdatedAt = preserveUpdatedAt(col.model, updatedAt);
await delegate.update({
where: { [pkField(col)]: id },
data: { [col.field]: next },
where: { [pkField(col)]: id, ...keepUpdatedAt },
data: { [col.field]: next, ...keepUpdatedAt },
});
result.rotated += 1;
} catch (err) {
// Deleted between the read and the write (a backup replacing its
// pieces, an account going): nothing is left to rotate.
// pieces, an account going), or edited since the read: nothing to
// rotate now; an edited row is picked up by the next run.
if (isRowGone(err)) return;
await onUnreadable(id);
}
@@ -37,6 +37,7 @@ function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
sleepPending: false,
score: { value: 82, band: "good", delta: 1 },
topSignal: null,
signalLine: null,
briefingLead: "Sleep looked solid last night.",
lead: { text: "Sleep looked solid last night.", source: "briefing" },
today: [],
@@ -677,6 +677,52 @@ describe("loadDailyDigest — Today overview inputs", () => {
expect(digest.briefingLead).toBeNull();
});
it("says a glucose range is the usual one for this time of day", async () => {
// Today's glucose is held against the same hours of earlier days, so the
// range it names is not the whole-day band shown elsewhere.
const { getServerTranslator: real } = await vi.importActual<
typeof import("@/lib/i18n/server-translator")
>("@/lib/i18n/server-translator");
const { getServerTranslator } =
await import("@/lib/i18n/server-translator");
vi.mocked(getServerTranslator).mockReturnValueOnce(real("en"));
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
...SNAPSHOT,
body: {
...SNAPSHOT.body,
briefingAi: aiUnavailable("user_disabled"),
},
} as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue(
aiUnavailable("user_disabled"),
);
vi.mocked(computeCoincidentDeviation).mockResolvedValueOnce({
status: "ok",
value: {
fired: false,
contributing: [],
day: "2026-07-17",
illnessExplained: false,
vitals: [
{
type: "BLOOD_GLUCOSE",
value: 70,
center: 86,
low: 80,
high: 92,
outside: true,
direction: "below",
daysAgo: 0,
basis: "sameHours",
},
],
},
} as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.lead?.source).toBe("signal");
expect(digest.lead?.text).toMatch(/80 to 92 mg\/dL for this time of day/);
});
it("publishes how long the score has held", async () => {
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
...SNAPSHOT,
@@ -617,3 +617,268 @@ describe("locales", () => {
);
});
});
describe("lead length — whole sentences, never a word cut in half", () => {
// The shape a live briefing produced: one sentence well past the lead's
// budget, which used to be sliced mid-word ("…sleep landed r…").
const LONG =
"Today's picture is a calm one: your most recent blood pressure sits comfortably in the optimal band, resting heart rate is low, and last night's sleep landed right on your usual.";
/** Every word of the lead is a whole word of the source text. */
function wordsAreWhole(lead: string, source: string) {
const sourceWords = new Set(source.split(/\s+/));
const words = lead.replace(/…$/, "").trim().split(/\s+/);
for (const word of words) {
expect(
sourceWords.has(word) ||
[...sourceWords].some((w) => w.replace(/[,;:]$/, "") === word),
).toBe(true);
}
}
it("leads with the first sentence when it fits", () => {
const o = buildTodayOverview(
input({
briefing: briefing(
"Your blood pressure sat in the optimal band all week. Sleep was steady too.",
),
}),
t,
);
expect(o.lead?.text).toBe(
"Your blood pressure sat in the optimal band all week.",
);
});
it("does not cut an over-long first sentence; the headline leads instead", () => {
const o = buildTodayOverview(
input({
briefing: briefing(
LONG,
"Your latest blood pressure is sitting in the optimal band.",
),
}),
t,
);
expect(o.lead).toEqual({
text: "Your latest blood pressure is sitting in the optimal band.",
source: "briefing",
});
});
it("falls to the deterministic sentence before it would cut model text", () => {
const o = buildTodayOverview(
input({ briefing: briefing(LONG), vitals: [vital()] }),
t,
);
expect(o.lead?.source).toBe("signal");
expect(o.lead?.text).not.toContain("…");
});
it("shortens at a word boundary only when nothing else is left", () => {
const o = buildTodayOverview(input({ briefing: briefing(LONG) }), t);
const text = o.lead?.text ?? "";
expect(text.length).toBeLessThanOrEqual(160);
expect(text.endsWith("…")).toBe(true);
wordsAreWhole(text, LONG);
expect(text).not.toMatch(/[,;:]…$/);
});
it("keeps whole reaction sentences that fit and drops the one that does not", () => {
const o = buildTodayOverview(
input({
reactionLine: `A solid night, deeper than your recent stretch. ${LONG}`,
}),
t,
);
expect(o.lead).toEqual({
text: "A solid night, deeper than your recent stretch.",
source: "reaction",
});
});
it("keeps briefingLead word-safe for the push line", () => {
const lead = firstSubstantiveSentence(LONG, null) ?? "";
expect(lead.length).toBeLessThanOrEqual(160);
wordsAreWhole(lead, LONG);
});
});
describe("signal line under the lead — nothing said twice", () => {
const BP_HEADLINE =
"Your latest blood pressure is sitting in the optimal band.";
const BP_DELTA = "↓ ~10 mmHg systolic vs the start of the window";
function withSignal(
paragraph: string,
signal: Partial<NonNullable<DailyBriefing["signalsOfDay"]>[number]> = {},
): DailyBriefing {
return {
paragraph,
signalsOfDay: [
{
sourceMetric: "bp",
tone: "good",
headline: BP_HEADLINE,
nudge: "Keep the routine.",
delta: BP_DELTA,
...signal,
},
],
keyFindings: [],
};
}
it("keeps only the delta when the lead already talks about the metric", () => {
const o = buildTodayOverview(
input({
briefing: withSignal(
"Your most recent blood pressure sits comfortably in the optimal band, and resting heart rate is low.",
),
}),
t,
);
expect(o.lead?.source).toBe("briefing");
expect(o.signalLine).toEqual({ headline: null, delta: BP_DELTA });
});
it("drops the line when the lead covers the metric and there is no delta", () => {
const o = buildTodayOverview(
input({
briefing: withSignal("Blood pressure held steady all week.", {
delta: null,
}),
}),
t,
);
expect(o.signalLine).toBeNull();
});
it("recognises the metric in the reader's language", () => {
const de = getServerTranslator("de").t;
const o = buildTodayOverview(
input({
locale: "de",
briefing: withSignal(
"Dein Blutdruck lag die ganze Woche im optimalen Bereich.",
{ headline: "Dein Blutdruck liegt im optimalen Bereich." },
),
}),
de,
);
expect(o.signalLine).toEqual({ headline: null, delta: BP_DELTA });
});
it("keeps headline and delta when the lead is about something else", () => {
const o = buildTodayOverview(
input({ briefing: withSignal("Last night's sleep ran long and deep.") }),
t,
);
expect(o.signalLine).toEqual({ headline: BP_HEADLINE, delta: BP_DELTA });
});
it("carries no line when the headline itself is the lead", () => {
const o = buildTodayOverview(
input({ briefing: withSignal("Your health score is 94.") }),
t,
);
expect(o.lead?.text).toBe(BP_HEADLINE);
expect(o.signalLine).toEqual({ headline: null, delta: BP_DELTA });
});
it("carries no line under a deterministic lead or without a briefing", () => {
expect(
buildTodayOverview(input({ vitals: [vital()] }), t).signalLine,
).toBeNull();
expect(buildTodayOverview(input(), t).signalLine).toBeNull();
});
});
describe("signal line — a metric is named by whole words, not by fragments", () => {
const DELTA = "↓ 50 min vs your usual";
function lineFor(
lead: string,
sourceMetric: NonNullable<
DailyBriefing["signalsOfDay"]
>[number]["sourceMetric"],
) {
return buildTodayOverview(
input({
briefing: {
paragraph: lead,
signalsOfDay: [
{
sourceMetric,
tone: "info",
headline: "A headline the lead does not quote.",
nudge: "n",
delta: DELTA,
},
],
keyFindings: [],
},
}),
t,
).signalLine;
}
// Each lead talks about something else; a fragment inside another word
// used to count as naming the metric, and the headline was dropped.
it.each([
["Your weight has risen slightly this week.", "sleep"], // "sen"
["You have chosen a steady routine.", "sleep"], // "sen"
["Your readings worsen a little after lunch.", "sleep"], // "sen"
["A snug fit for the cuff gave a clean reading.", "sleep"], // "snu"
["Poranne ciśnienie spadło po spacerze.", "sleep"], // "spa" (pl)
["잠시 후 다시 측정해 보세요.", "sleep"], // 잠시 = "a moment"
["Passing showers kept the walk short.", "steps"], // "passi"
["Ho passato una giornata tranquilla.", "steps"], // "passi" (it) inside passato
["A sudden impulse to rest was a good call.", "pulse"], // "puls"
["Der Impuls zur Pause war richtig.", "pulse"], // "puls" (de)
["L'impulsione del giorno era calma.", "pulse"], // "puls" (it)
["Wypadł impuls do odpoczynku.", "pulse"], // "puls" (pl)
] as const)("keeps the headline under %j (%s)", (lead, metric) => {
const line = lineFor(lead, metric);
expect(line?.headline).toBe("A headline the lead does not quote.");
});
it.each([
["Last night you slept seven hours.", "sleep"],
["Dein Schlaf war ruhig.", "sleep"],
["Tu sueño fue tranquilo.", "sleep"],
["Tu as bien dormi.", "sleep"],
["Il sonno è stato regolare.", "sleep"],
["Twój sen był spokojny.", "sleep"],
["Mało snu tej nocy.", "sleep"],
["Spałeś siedem godzin.", "sleep"],
["어젯밤 잠을 잘 잤어요.", "sleep"],
["수면 시간이 길었어요.", "sleep"],
["Hai fatto molti passi oggi.", "steps"],
["Your pulse settled overnight.", "pulse"],
["Dein Puls war ruhig.", "pulse"],
["Twój puls był spokojny.", "pulse"],
["Il polso era regolare.", "pulse"],
["Ton pouls était calme.", "pulse"],
] as const)("still recognises %j as naming %s", (lead, metric) => {
expect(lineFor(lead, metric)).toEqual({ headline: null, delta: DELTA });
});
});
describe("lead length — the ellipsis counts toward the budget", () => {
it("stays within 160 characters for text without a single space", () => {
// A digit keeps it from reading as a greeting (too few words).
const unbroken = `${"a".repeat(400)}1.`;
const lead = firstSubstantiveSentence(unbroken, null) ?? "";
expect(lead.endsWith("…")).toBe(true);
expect(lead.length).toBeLessThanOrEqual(160);
});
it("stays within 160 characters when the last space sits at the budget", () => {
const text = `${"b".repeat(159)} ${"c".repeat(39)}1.`;
const lead = firstSubstantiveSentence(text, null) ?? "";
expect(lead.length).toBeLessThanOrEqual(160);
// The space at the edge still ends a whole word.
expect(lead).toBe(`${"b".repeat(159)}…`);
});
});
+8
View File
@@ -62,6 +62,7 @@ import {
type TodayCycle,
type TodayFact,
type TodayLead,
type TodaySignalLine,
type TodayRestMode,
type TodaySleep,
type TodayVital,
@@ -441,6 +442,12 @@ export interface DailyDigest {
* day's strongest signal. Null when there is nothing to say.
*/
lead: TodayLead | null;
/**
* The muted line under an AI lead: the top signal minus what the lead
* already says (the headline goes when the lead covers its metric, and the
* line is null when nothing is left). Null under a deterministic lead.
*/
signalLine: TodaySignalLine | null;
/**
* The Today overview: up to five statements about the day (Rest Mode,
* medications, an appointment today or tomorrow, last night, vitals,
@@ -1284,6 +1291,7 @@ export function buildDailyDigest(
topSignal,
briefingLead: overview.briefingLead,
lead: overview.lead,
signalLine: overview.signalLine,
today: overview.today,
restMode: moduleEnabled(input.modules, "illness")
? (input.restMode ?? null)
+15 -5
View File
@@ -259,6 +259,8 @@ interface ExtrasVital {
high: number;
direction: "above" | "below" | "in";
daysAgo: number;
/** Held against the same hours of earlier days, see `VitalDeviation`. */
basis?: "sameHours";
}
/**
@@ -286,6 +288,7 @@ async function gatherVitals(
high: v.high,
direction: v.direction,
daysAgo: Number.isFinite(v.daysAgo) ? v.daysAgo : Number.MAX_SAFE_INTEGER,
...(v.basis ? { basis: v.basis } : {}),
}));
} catch {
return [];
@@ -370,11 +373,18 @@ function toTodayVital(
value: shown(vital.value),
unit,
}),
rangeLabel: t("daily.today.rangeWithUnit", {
low: shown(vital.low),
high: shown(vital.high),
unit,
}),
rangeLabel: (() => {
const range = t("daily.today.rangeWithUnit", {
low: shown(vital.low),
high: shown(vital.high),
unit,
});
// A range read off the same hours of earlier days is the usual range
// for this time of day, not the whole-day band shown elsewhere.
return vital.basis === "sameHours"
? t("daily.today.rangeAtThisTime", { range })
: range;
})(),
moduleKey: moduleForMeasurementType(vital.type),
};
}
+243 -27
View File
@@ -18,7 +18,7 @@
* fact leaves the list; an appointment already on the rail is not repeated
* under Today.
*/
import type { DailyBriefing } from "@/lib/ai/schema";
import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
import type { CyclePhase } from "@/lib/cycle/types";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { Locale } from "@/lib/i18n/config";
@@ -154,8 +154,20 @@ export interface TodayOverviewInput {
cycle: TodayCycle | null;
}
/**
* The muted line under the lead: the briefing's top signal, minus whatever
* the lead already says. `headline` is null when the lead covers the signal's
* metric; the line is absent altogether when nothing is left.
*/
export interface TodaySignalLine {
headline: string | null;
delta: string | null;
}
export interface TodayOverview {
lead: TodayLead | null;
/** The supporting line under an AI lead, already de-duplicated. */
signalLine: TodaySignalLine | null;
today: TodayFact[];
/** The first substantive briefing sentence, for `briefingLead`. */
briefingLead: string | null;
@@ -249,45 +261,229 @@ function repeatsScore(sentence: string, score: number | null): boolean {
return false;
}
function clampLead(sentence: string): string {
return sentence.length > MAX_LEAD_LENGTH
? `${sentence.slice(0, MAX_LEAD_LENGTH - 1).trimEnd()}…`
: sentence;
/** Whether a sentence (or a run of them) fits the lead's length budget. */
function fitsLead(text: string): boolean {
return text.length <= MAX_LEAD_LENGTH;
}
/**
* Shorten text that does not fit, at a word boundary, as the last resort.
*
* Slicing at a character count cut words in half ("…sleep landed r…"). The
* cut now falls on the last space inside the budget, and trailing clause
* punctuation goes with it, so what is left ends on a whole word.
*/
function shortenAtWord(text: string): string {
if (fitsLead(text)) return text;
// One character of the budget goes to the ellipsis itself, so the kept
// head is at most MAX - 1 long: a space at that index still ends a whole
// word, and text with no space at all is cut hard one short of the budget.
const space = text.slice(0, MAX_LEAD_LENGTH).lastIndexOf(" ");
const head = (
space > 0 ? text.slice(0, space) : text.slice(0, MAX_LEAD_LENGTH - 1)
)
.trimEnd()
.replace(/[\s,;:–—-]+$/u, "");
return `${head}…`;
}
/** The sentences of a paragraph that say something, in order. */
function substantiveSentences(
paragraph: string | null | undefined,
score: number | null,
): string[] {
if (!paragraph) return [];
return sentences(paragraph).filter(
(s) => !isGreetingOnly(s) && !repeatsScore(s, score),
);
}
/**
* The first sentence of a paragraph that says something: not a greeting, and
* not a restatement of the score inside the ring. Null when no sentence
* qualifies, so the caller falls through to the next source rather than
* showing filler.
* showing filler. A sentence past the length budget is shortened at a word
* boundary; it feeds `briefingLead` and the push line, which have no other
* source to fall to.
*/
export function firstSubstantiveSentence(
paragraph: string | null | undefined,
score: number | null,
): string | null {
if (!paragraph) return null;
for (const sentence of sentences(paragraph)) {
if (isGreetingOnly(sentence)) continue;
if (repeatsScore(sentence, score)) continue;
return clampLead(sentence);
}
return null;
const first = substantiveSentences(paragraph, score)[0];
return first ? shortenAtWord(first) : null;
}
/**
* The model's reaction line, minus any greeting or score sentence. Unlike the
* briefing it may keep more than one sentence: it is written to be read whole.
* It keeps whole sentences from the start while they fit the budget, and is
* null when even the first one does not, so the lead falls to a source that
* can be shown complete.
*/
function cleanReactionLine(
line: string | null,
score: number | null,
): string | null {
if (!line) return null;
const kept = sentences(line).filter(
(s) => !isGreetingOnly(s) && !repeatsScore(s, score),
);
const text = kept.join(" ").replace(/\s+/g, " ").trim();
return text.length > 0 ? clampLead(text) : null;
let text = "";
for (const sentence of substantiveSentences(line, score)) {
const next = text ? `${text} ${sentence}` : sentence;
if (!fitsLead(next.replace(/\s+/g, " "))) break;
text = next.replace(/\s+/g, " ");
}
return text.length > 0 ? text : null;
}
/** Lowercase letters and digits only, for a wording-insensitive compare. */
function normalise(text: string): string {
return text
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
/** Whether `text` already says what `phrase` says, wording aside. */
function restates(text: string, phrase: string): boolean {
const p = normalise(phrase);
return p.length > 0 && normalise(text).includes(p);
}
/**
* A term that must start a word: `stem` lets an inflection follow it
* ("Blutdrucks", "tętna"), `word` must end where the word ends. Unicode-aware
* on both sides, since `\b` only knows ASCII letters and would split "spał".
* Without the boundaries a fragment inside another word named the metric:
* "risen" read as the Polish "sen" (sleep), "impulse" as "puls".
*/
const START = "(?<![\\p{L}\\p{N}])";
const END = "(?![\\p{L}\\p{N}])";
const stem = (...terms: string[]) => terms.map((x) => `${START}${x}`);
const word = (...terms: string[]) => terms.map((x) => `${START}${x}${END}`);
/** Hangul has no word boundary to lean on: the term carries its particle. */
const hangul = (...terms: string[]) => terms;
const terms = (...parts: string[][]) =>
new RegExp(parts.flat().join("|"), "iu");
const WEIGHT_TERMS = terms(
stem("weigh", "gewicht"),
word("peso", "pesos", "poids", "wag[aięe]", "wadze"),
hangul("체중", "몸무게"),
);
/**
* How each briefing metric is named in prose, in every shipped language. The
* lead is model text in the reader's language, so "does the lead already talk
* about this metric" is a question about words, matched at word starts. A
* metric missing here simply falls back to the headline comparison.
*/
const METRIC_TERMS: Partial<
Record<DailyBriefingSignal["sourceMetric"], RegExp>
> = {
bp: terms(
stem(
"blood\\s*pressure",
"blutdruck",
"presi[oó]n\\s+arterial",
"tensi[oó]n\\s+arterial",
"pression\\s+art[ée]rielle",
"tension\\s+art[ée]rielle",
"pressione\\s+(arteriosa|sanguigna)",
"ci[sś]nieni",
),
hangul("혈압"),
),
weight: WEIGHT_TERMS,
glp1_plateau: WEIGHT_TERMS,
pulse: terms(
word("pulses?", "puls(es)?", "pouls", "polso", "pulso"),
stem("t[eę]tn"),
hangul("맥박"),
),
resting_hr: terms(
stem(
"resting\\s+(heart\\s*rate|pulse)",
"ruhepuls",
"ruhe-?herzfrequenz",
"en\\s+reposo",
"au\\s+repos",
"a\\s+riposo",
"spoczynkow",
),
hangul("안정\\s*시?\\s*심박"),
),
hrv: terms(
word("hrv"),
stem(
"heart[-\\s]*rate\\s+variability",
"herzfrequenzvariabilit",
"variabilidad\\s+de\\s+la\\s+frecuencia",
"variabilit[ée]\\s+de\\s+la\\s+fr[ée]quence",
"variabilit[àa]\\s+della\\s+frequenza",
"zmienno[sś][cć]\\s+rytmu",
),
hangul("심박\\s*변이"),
),
sleep: terms(
stem("sleep", "schlaf", "geschlafen", "dormi"),
word(
"slept",
"sue[nñ]o",
"sommeil",
"sonno",
"sen",
"snu",
"spa[lł](a|e[sś]|a[sś]|y)?",
),
hangul("수면", "잠을", "잠이", "잠은", "잠도"),
),
steps: terms(
word("steps", "schritte", "schritten", "pasos", "passi", "krok(i|ów|ach)"),
hangul("걸음"),
),
mood: terms(
stem("stimmung"),
word("mood", "[aá]nimo", "humeur", "umore", "nastr(ój|oj|oju)"),
hangul("기분"),
),
compliance: terms(
stem("medication", "medikament", "einnahme", "m[ée]dicament", "farmac"),
word("doses?", "dosis", "medicaci[oó]n", "lek(i|ów|u|ami)?"),
hangul("복약", "약물"),
),
body_temp: terms(stem("temperatur", "temp[ée]rature"), hangul("체온")),
vo2_max: terms(stem("vo2", "vo₂")),
};
/** Whether the lead already talks about the metric a signal is drawn from. */
function leadCoversMetric(
lead: string,
metric: DailyBriefingSignal["sourceMetric"],
): boolean {
return METRIC_TERMS[metric]?.test(lead) ?? false;
}
/**
* The line under the lead, decided here so every client shows the same one.
*
* Only an AI lead carries it (a deterministic lead is already the strongest
* signal). When the lead already talks about the top signal's metric, or
* says its headline outright, the headline goes and the delta stands alone;
* with no delta left there is no line.
*/
function buildSignalLine(
lead: TodayLead | null,
signal: DailyBriefingSignal | null,
): TodaySignalLine | null {
if (!lead || lead.source === "signal" || !signal) return null;
const headline = signal.headline?.trim() || null;
const delta = signal.delta?.trim() || null;
const covered =
(headline !== null && restates(lead.text, headline)) ||
leadCoversMetric(lead.text, signal.sourceMetric);
const keptDelta = delta && !restates(lead.text, delta) ? delta : null;
if (!covered && headline) return { headline, delta: keptDelta };
return keptDelta ? { headline: null, delta: keptDelta } : null;
}
/** Vitals that may speak about today: module on, reading current. */
@@ -562,6 +758,11 @@ function cycleFact(input: TodayOverviewInput, t: Translate): TodayFact | null {
* their capabilities are available; the deterministic sentence is what makes
* the card whole without them.
*
* A lead is shown whole or not at all: model text that does not fit
* {@link MAX_LEAD_LENGTH} gives way to the next source, and only when no
* source fits is the first model sentence shortened, at a word boundary. The
* budget is also what keeps the hero's height steady from day to day.
*
* Facts follow a fixed priority (Rest Mode, medications, an appointment today
* or tomorrow, last night, vitals, cycle) and are capped at
* {@link MAX_TODAY_FACTS}. The list is never padded: a quiet account gets as
@@ -573,26 +774,37 @@ export function buildTodayOverview(
): TodayOverview {
const sleep = moduleOn(input.modules, "sleep") ? input.sleep : null;
const briefingLead = firstSubstantiveSentence(
input.briefing?.paragraph,
input.scoreValue,
);
const briefingSentence =
substantiveSentences(input.briefing?.paragraph, input.scoreValue)[0] ??
null;
const briefingLead = briefingSentence
? shortenAtWord(briefingSentence)
: null;
const reaction = cleanReactionLine(input.reactionLine, input.scoreValue);
const headline = input.briefing?.signalsOfDay?.[0]?.headline?.trim() || null;
const rawHeadline =
input.briefing?.signalsOfDay?.[0]?.headline?.trim() || null;
const headline =
rawHeadline && !repeatsScore(rawHeadline, input.scoreValue)
? rawHeadline
: null;
let lead: TodayLead | null = null;
let consumed: TodayFactKind | null = null;
if (reaction) {
lead = { text: reaction, source: "reaction" };
} else if (briefingLead) {
lead = { text: briefingLead, source: "briefing" };
} else if (headline && !repeatsScore(headline, input.scoreValue)) {
lead = { text: clampLead(headline), source: "briefing" };
} else if (briefingSentence && fitsLead(briefingSentence)) {
lead = { text: briefingSentence, source: "briefing" };
} else if (headline && fitsLead(headline)) {
lead = { text: headline, source: "briefing" };
} else {
const signal = signalLead(input, sleep, t);
if (signal) {
lead = { text: signal.text, source: "signal" };
consumed = signal.consumes;
} else if (briefingLead) {
lead = { text: briefingLead, source: "briefing" };
} else if (headline) {
lead = { text: shortenAtWord(headline), source: "briefing" };
}
}
@@ -609,6 +821,10 @@ export function buildTodayOverview(
return {
lead,
signalLine: buildSignalLine(
lead,
input.briefing?.signalsOfDay?.[0] ?? null,
),
today: facts.slice(0, MAX_TODAY_FACTS),
briefingLead,
};
@@ -61,4 +61,15 @@ describe("summariseHealthStatus", () => {
const s = summariseHealthStatus([vital(true, "in")], []);
expect(s.present).toBe(false);
});
it("says when a deviation is measured against the same hours", () => {
const s = summariseHealthStatus(
[
{ ...vital(true, "below"), type: "BLOOD_GLUCOSE", basis: "sameHours" },
vital(true, "above"),
],
[],
);
expect(s.deviations.map((d) => d.basis)).toEqual(["sameHours", "day"]);
});
});
@@ -70,6 +70,50 @@ describe("pure statistics", () => {
expect(band!.k).toBe(3);
});
// A run of identical days has no dispersion at all: the bare MAD band
// collapsed to one value and every surface read "your usual range is
// 61–61 bpm", while a reading of 62 counted as outside it.
it("never collapses to a single value when the days are identical", () => {
const band = buildBaselineBand(Array(20).fill(61), "RESTING_HEART_RATE");
expect(band).not.toBeNull();
expect(band!.center).toBe(61);
expect(band!.low).toBeLessThan(61);
expect(band!.high).toBeGreaterThan(61);
// Distinct at the precision any surface prints a heart rate.
expect(Math.round(band!.low)).not.toBe(Math.round(band!.high));
// An ordinary next-day reading sits inside the band, not above it.
expect(62).toBeLessThanOrEqual(band!.high);
// The dispersion itself is reported as it is.
expect(band!.spread).toBe(0);
});
it.each([
["RESTING_HEART_RATE", 61],
["PULSE", 72],
["HEART_RATE_VARIABILITY", 45],
["RESPIRATORY_RATE", 14],
["OXYGEN_SATURATION", 97],
["BODY_TEMPERATURE", 36.6],
["SKIN_TEMPERATURE", 34.1],
["BLOOD_PRESSURE_SYS", 118],
["BLOOD_PRESSURE_DIA", 76],
["BLOOD_GLUCOSE", 95],
["WEIGHT", 80],
["VO2_MAX", 42],
["ACTIVITY_STEPS", 8000],
] as const)(
"gives %s a band that reads as a range at one decimal",
(type, value) => {
const band = buildBaselineBand(Array(10).fill(value), type);
expect(band!.low.toFixed(1)).not.toBe(band!.high.toFixed(1));
},
);
it("leaves a band wider than the floor exactly as the spread makes it", () => {
const band = buildBaselineBand([50, 52, 54, 56, 58], "PULSE", 3);
expect(band!.high - band!.center).toBeCloseTo(band!.spread, 10);
});
it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => {
// Same per-day means whether they arrive as raw rows or DAY rollups.
const perDayMeans = [60, 61, 62, 59, 63, 58, 64];
@@ -33,6 +33,7 @@ import {
computeCoincidentDeviation,
classifyDeviation,
COINCIDENT_FIRE_THRESHOLD,
sameHoursStanding,
} from "../coincident-deviation";
const PROFILE = { ageYears: 40, sex: "MALE" as const };
@@ -234,3 +235,157 @@ describe("computeCoincidentDeviation", () => {
expect(restModeMock.resolveRestMode).not.toHaveBeenCalled();
});
});
describe("glucose — a day in progress is compared with the same hours", () => {
/** Fourteen whole days: fasting 85 at 07:00, then 160 and 150 after meals. */
function glucoseDays(times: string[], values: number[]) {
return Array.from({ length: 14 }, (_, i) =>
times.map((time, j) => ({
value: values[j],
measuredAt: new Date(
`2026-05-${String(18 + i).padStart(2, "0")}T${time}:00Z`,
),
})),
).flat();
}
const rhrFlat = Array.from({ length: 10 }, (_, i) => ({
value: 58,
measuredAt: new Date(
`2026-05-${String(20 + i).padStart(2, "0")}T06:00:00Z`,
),
}));
it("does not call a fasting morning 'below your range' against whole days", async () => {
// 08:00 today: only the fasting reading is in. Against whole-day means
// (~132) it read as below the range; against the same hours (85) it is
// an ordinary morning.
findMany.mockImplementation(async (args: { where: { type: string } }) => {
if (args.where.type === "BLOOD_GLUCOSE") {
return [
...glucoseDays(["07:00", "13:00", "19:00"], [85, 160, 150]),
{ value: 86, measuredAt: new Date("2026-06-02T07:00:00Z") },
];
}
if (args.where.type === "RESTING_HEART_RATE") return rhrFlat;
return [];
});
const result = await computeCoincidentDeviation("u1", PROFILE, {
now: NOW,
tz: "UTC",
});
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
const glucose = result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE");
expect(glucose).toBeDefined();
expect(glucose!.daysAgo).toBe(0);
expect(glucose!.value).toBe(86);
expect(glucose!.direction).toBe("in");
expect(glucose!.center).toBe(85);
});
it("cuts earlier days at today's latest reading, not at the clock", async () => {
// 14:00, and today holds only the 07:00 fasting value. Cut at 14:00 the
// earlier days include their 13:00 post-meal readings (mean ~122) and the
// morning read as low; cut at 07:00 they are fasting too.
findMany.mockImplementation(async (args: { where: { type: string } }) => {
if (args.where.type === "BLOOD_GLUCOSE") {
return [
...glucoseDays(["07:00", "13:00", "19:00"], [85, 160, 150]),
{ value: 84, measuredAt: new Date("2026-06-02T07:00:00Z") },
];
}
if (args.where.type === "RESTING_HEART_RATE") return rhrFlat;
return [];
});
const result = await computeCoincidentDeviation("u1", PROFILE, {
now: new Date("2026-06-02T14:00:00Z"),
tz: "UTC",
});
if (result.status !== "ok") throw new Error("expected ok");
const glucose = result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE");
expect(glucose!.direction).toBe("in");
expect(glucose!.center).toBe(85);
expect(glucose!.basis).toBe("sameHours");
});
it("still flags a morning that is low against earlier mornings", async () => {
findMany.mockImplementation(async (args: { where: { type: string } }) => {
if (args.where.type === "BLOOD_GLUCOSE") {
return [
...glucoseDays(["07:00", "13:00", "19:00"], [85, 160, 150]),
{ value: 62, measuredAt: new Date("2026-06-02T07:00:00Z") },
];
}
if (args.where.type === "RESTING_HEART_RATE") return rhrFlat;
return [];
});
const result = await computeCoincidentDeviation("u1", PROFILE, {
now: NOW,
tz: "UTC",
});
if (result.status !== "ok") throw new Error("expected ok");
const glucose = result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE");
expect(glucose!.direction).toBe("below");
});
it("says nothing about today when earlier days have no readings at this hour", async () => {
findMany.mockImplementation(async (args: { where: { type: string } }) => {
if (args.where.type === "BLOOD_GLUCOSE") {
return [
...glucoseDays(["19:00"], [150]),
{ value: 86, measuredAt: new Date("2026-06-02T07:00:00Z") },
];
}
if (args.where.type === "RESTING_HEART_RATE") return rhrFlat;
if (args.where.type === "HEART_RATE_VARIABILITY") {
return rhrFlat.map((r) => ({ ...r, value: 60 }));
}
return [];
});
const result = await computeCoincidentDeviation("u1", PROFILE, {
now: NOW,
tz: "UTC",
});
if (result.status !== "ok") throw new Error("expected ok");
expect(
result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE"),
).toBeUndefined();
});
});
describe("sameHoursStanding", () => {
it("reads an hourly-mean day by its day mean, not by sum / n", () => {
// Twelve dense readings in one hour and one in another: the mean of the
// hours' means is 100, the raw mean is 140. A type on the hourly-mean
// reader carries `dayMean`, and that is the day's value.
const day = (d: string, dayMean: number) => ({
day: d,
n: 13,
sum: 140 * 13,
dayMean,
});
const days = [
...Array.from({ length: 8 }, (_, i) =>
day(`2026-05-${String(20 + i).padStart(2, "0")}`, 100),
),
day("2026-06-02", 101),
];
const standing = sameHoursStanding(days, "2026-06-02", "BLOOD_GLUCOSE");
expect(standing?.value).toBe(101);
expect(standing?.center).toBe(100);
});
it("falls back to sum / n where the reader has no day mean", () => {
const days = [
...Array.from({ length: 8 }, (_, i) => ({
day: `2026-05-${String(20 + i).padStart(2, "0")}`,
n: 2,
sum: 180,
})),
{ day: "2026-06-02", n: 1, sum: 88 },
];
const standing = sameHoursStanding(days, "2026-06-02", "BLOOD_GLUCOSE");
expect(standing?.value).toBe(88);
expect(standing?.center).toBe(90);
});
});
+11 -4
View File
@@ -34,6 +34,7 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import {
clampDerivedLowerBound,
minimumBandHalfWidth,
isPlausibleMetricValue,
plausibleMetricRange,
} from "@/lib/measurements/value-domain";
@@ -70,12 +71,13 @@ export interface VitalsBaselineValue {
/** Robust center (median of the per-day means). */
center: number;
/**
* Band lower edge (center − k·MAD·scale), floored at 0 for a metric whose
* Band lower edge (center − k·MAD·scale, at least the metric's
* `minimumBandHalfWidth` below center), floored at 0 for a metric whose
* plausibility domain forbids negative values. `spread` below stays the
* unclamped dispersion, so a deviation-in-σ calculation is unaffected.
*/
low: number;
/** Band upper edge (center + k·MAD·scale). */
/** Band upper edge (center + k·MAD·scale, with the same minimum width). */
high: number;
/** The MAD-derived σ-equivalent spread (k applied; same units as the metric). */
spread: number;
@@ -204,10 +206,15 @@ export function buildBaselineBand(
const mad = medianAbsoluteDeviation(dayMeans);
// Scale MAD to a σ-equivalent so the band reads like a robust ±kσ.
const spread = k * mad * MAD_SIGMA_SCALE;
// The edges never sit closer than the measurement's own day-to-day wobble:
// a run of identical days has no dispersion, and a zero-width band read
// "61–61 bpm" and put the next ordinary reading outside it. `spread` stays
// the measured dispersion for anything that reasons in σ.
const halfWidth = Math.max(spread, minimumBandHalfWidth(type));
return {
center,
low: clampDerivedLowerBound(type, center - spread),
high: center + spread,
low: clampDerivedLowerBound(type, center - halfWidth),
high: center + halfWidth,
spread,
sampleDays: dayMeans.length,
k,
+145 -15
View File
@@ -36,11 +36,20 @@ import {
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import {
buildBaselineBand,
computeVitalsBaseline,
type BaselineProfile,
} from "./baseline";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
import {
isPlausibleMetricValue,
plausibleMetricRange,
} from "@/lib/measurements/value-domain";
import {
dayKeyAgeInDays,
isCurrentForTodayClaim,
@@ -61,6 +70,84 @@ export const COINCIDENT_FIRE_THRESHOLD = 2;
/** Need ≥ this many banded vitals before the flag can even coincide. */
export const COINCIDENT_MIN_BANDS = 2;
/**
* Vitals whose day mean depends on how much of the day has passed.
*
* Glucose climbs after every meal, so a day's mean at ten in the morning is a
* fasting mean, and a band built from whole days sits well above it: a
* perfectly ordinary morning read as "below your range". For these types a
* day still in progress is compared with the same hours of the earlier days
* (each day cut at the local time of today's latest reading), never with
* whole days.
*/
const SAME_HOURS_TYPES: ReadonlySet<MeasurementType> = new Set([
"BLOOD_GLUCOSE",
]);
/** Earlier days with readings in the same hours before today is compared. */
export const SAME_HOURS_MIN_DAYS = 7;
/**
* Today's standing for a same-hours type, from per-day aggregates already cut
* at the current local clock time. Pure. Null when today has no reading in
* those hours or fewer than {@link SAME_HOURS_MIN_DAYS} earlier days do: then
* there is nothing like-for-like to compare today with, and no verdict.
*/
export function sameHoursStanding(
days: readonly { day: string; n: number; sum: number; dayMean?: number }[],
todayKey: string,
type: MeasurementType,
): { value: number; low: number; high: number; center: number } | null {
// A type on the hourly-mean reader carries `dayMean` (the mean of its
// hours' means); every other type's day is the mean of its readings.
const value = (d: { n: number; sum: number; dayMean?: number }) =>
d.dayMean ?? d.sum / d.n;
const today = days.find((d) => d.day === todayKey && d.n > 0);
if (!today) return null;
const earlier = days.filter((d) => d.day < todayKey && d.n > 0).map(value);
if (earlier.length < SAME_HOURS_MIN_DAYS) return null;
const band = buildBaselineBand(earlier, type);
if (!band) return null;
return {
value: value(today),
low: band.low,
high: band.high,
center: band.center,
};
}
/** The local clock time of an instant in `tz`, `HH:MM:SS`. */
function localClockOf(at: Date, tz: string): string {
const c = wallClockInTz(at, tz);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(c.hour)}:${pad(c.minute)}:${pad(c.second)}`;
}
/**
* Per-day aggregates of the window, each day cut at the local clock time of
* today's latest reading. Not at `now`: at two in the afternoon a day whose
* only reading is the 07:00 fasting value would otherwise be held against
* earlier days that include their lunch.
*/
async function readSameHoursDays(
userId: string,
type: MeasurementType,
windowDays: number,
now: Date,
tz: string,
cutAt: Date,
) {
return readDayAggregates({
userId,
type,
since: new Date(now.getTime() - windowDays * MS_PER_DAY),
until: now,
timeZone: tz,
valueRange: plausibleMetricRange(type),
upToLocalTime: localClockOf(cutAt, tz),
});
}
/** One vital's standing against its personal band today. */
export interface VitalDeviation {
type: MeasurementType;
@@ -84,6 +171,14 @@ export interface VitalDeviation {
* `contributing` below already does.
*/
daysAgo: number;
/**
* `"sameHours"` when today's value was held against the same hours of the
* earlier days rather than their whole-day band (a day still in progress
* for a type whose day mean moves with the hour, see `SAME_HOURS_TYPES`).
* `low` / `high` are then the usual range for this time of day, and a
* surface naming them says so. Absent for the whole-day comparison.
*/
basis?: "sameHours";
}
export interface CoincidentDeviationValue {
@@ -117,6 +212,7 @@ export function classifyDeviation(
high: number,
center: number,
daysAgo: number = 0,
basis?: "sameHours",
): VitalDeviation {
const above = value > high;
const below = value < low;
@@ -129,6 +225,7 @@ export function classifyDeviation(
outside: above || below,
direction: above ? "above" : below ? "below" : "in",
daysAgo,
...(basis ? { basis } : {}),
};
}
@@ -163,7 +260,7 @@ async function readLatestDayMean(
windowDays: number,
now: Date,
tz: string,
): Promise<{ value: number; day: string } | null> {
): Promise<{ value: number; day: string; lastAt: Date } | null> {
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
@@ -190,7 +287,7 @@ export function latestDayMeanFromRows(
rows: readonly { value: number; measuredAt: Date }[],
type: MeasurementType,
tz: string,
): { value: number; day: string } | null {
): { value: number; day: string; lastAt: Date } | null {
const usable = rows.filter((r) => isPlausibleMetricValue(type, r.value));
if (usable.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering)
@@ -204,6 +301,7 @@ export function latestDayMeanFromRows(
return {
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
day,
lastAt: new Date(Math.max(...sameDay.map((r) => r.measuredAt.getTime()))),
};
}
@@ -242,28 +340,60 @@ export async function computeCoincidentDeviation(
now,
coverage,
});
if (baseline.status !== "ok") return { type, baseline, latest: null };
if (baseline.status !== "ok") {
return { type, baseline, latest: null, sameHours: null };
}
const latest = await readLatestDayMean(userId, type, windowDays, now, tz);
return { type, baseline, latest };
// A day still in progress is compared with the same hours of earlier
// days, not with their whole-day band (see `SAME_HOURS_TYPES`).
const sameHours =
latest && latest.day === todayKey && SAME_HOURS_TYPES.has(type)
? sameHoursStanding(
await readSameHoursDays(
userId,
type,
windowDays,
now,
tz,
latest.lastAt,
),
todayKey,
type,
)
: undefined;
return { type, baseline, latest, sameHours };
}),
);
for (const { type, baseline, latest } of perType) {
for (const { type, baseline, latest, sameHours } of perType) {
if (baseline.status !== "ok") continue;
if (baseline.provenance.source === "DAY") anyDaySource = true;
if (!latest) continue;
// A same-hours type with no like-for-like basis for today yet says
// nothing about today rather than hold a partial day against whole ones.
if (sameHours === null) continue;
if (latest.day > latestDay) latestDay = latest.day;
if (baseline.coverage.historyDays > maxHistoryDays) {
maxHistoryDays = baseline.coverage.historyDays;
}
vitals.push(
classifyDeviation(
type,
latest.value,
baseline.value.low,
baseline.value.high,
baseline.value.center,
dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY,
),
sameHours
? classifyDeviation(
type,
sameHours.value,
sameHours.low,
sameHours.high,
sameHours.center,
0,
"sameHours",
)
: classifyDeviation(
type,
latest.value,
baseline.value.low,
baseline.value.high,
baseline.value.center,
dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY,
),
);
}
+7
View File
@@ -30,6 +30,12 @@ export interface HealthStatusDeviation {
high: number;
/** Which side of the band the value falls on. */
direction: "above" | "below";
/**
* What the band describes: the whole day (`day`), or the same hours of the
* earlier days (`sameHours`), for a day still in progress whose mean moves
* with the hour (glucose). A surface naming the range says which.
*/
basis: "day" | "sameHours";
}
/** One dated, sustained level shift from the changepoint detector. */
@@ -75,6 +81,7 @@ export function summariseHealthStatus(
low: v.low,
high: v.high,
direction: v.direction === "above" ? "above" : "below",
basis: v.basis ?? "day",
}));
const mappedShifts: HealthStatusShift[] = shifts.map((s) => ({
@@ -12,6 +12,7 @@ interface ConversationRow {
userId: string;
title: string | null;
titleEncrypted: Uint8Array | null;
updatedAt?: Date;
}
interface EntryRow {
id: string;
@@ -26,6 +27,7 @@ interface PractitionerRow {
location: string | null;
phoneEncrypted: Uint8Array | null;
locationEncrypted: Uint8Array | null;
updatedAt?: Date;
}
interface RouteRow {
id: string;
@@ -67,22 +69,49 @@ vi.mock("@/lib/db", () => {
},
findUnique: async (args: { where: { id: string } }) =>
rows().find((x) => x.id === args.where.id) ?? null,
update: async (args: { where: { id: string }; data: Partial<R> }) => {
const r = rows().find((x) => x.id === args.where.id)!;
// Prisma's DbNull sentinel stores SQL NULL.
const data = Object.fromEntries(
Object.entries(args.data).map(([k, v]) => [
k,
v !== null && typeof v === "object" && !(v instanceof Uint8Array)
? null
: v,
]),
);
Object.assign(r, data);
return r;
update: async (args: { where: { id: string }; data: Partial<R> }) =>
write(rows, args.where.id, args.data),
// The storage-only rewrites match on the row's own `updatedAt` and
// carry it forward; a row whose stamp moved meanwhile is not written.
updateMany: async (args: {
where: { id: string; updatedAt?: Date };
data: Partial<R>;
}) => {
const r = rows().find((x) => x.id === args.where.id) as
(R & { updatedAt?: Date }) | undefined;
if (!r) return { count: 0 };
if (
args.where.updatedAt !== undefined &&
r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()
) {
return { count: 0 };
}
write(rows, args.where.id, args.data);
return { count: 1 };
},
};
}
function write<R extends { id: string }>(
rows: () => R[],
id: string,
patch: Partial<R>,
): R {
const r = rows().find((x) => x.id === id)!;
// Prisma's DbNull sentinel stores SQL NULL; a Date is a real value.
const data = Object.fromEntries(
Object.entries(patch).map(([k, v]) => [
k,
v !== null &&
typeof v === "object" &&
!(v instanceof Uint8Array) &&
!(v instanceof Date)
? null
: v,
]),
);
Object.assign(r, data);
return r;
}
const delegates = {
coachConversation: delegate(() => store.conversations, ["title"]),
customMetricEntry: delegate(() => store.entries, ["note"]),
@@ -126,6 +155,7 @@ beforeEach(() => {
userId: "u1",
title: "Why is my pressure up after the new tablets?",
titleEncrypted: null,
updatedAt: new Date("2026-03-02T09:15:00.000Z"),
},
// Already migrated: left alone.
{
@@ -218,6 +248,9 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
expect(readNote(c1.titleEncrypted, null)).toBe(
"Why is my pressure up after the new tablets?",
);
// Sealing a title is not activity: the Coach panel orders and groups by
// updatedAt, so the conversation keeps the stamp it had.
expect(c1.updatedAt).toEqual(new Date("2026-03-02T09:15:00.000Z"));
const e1 = store.entries.find((r) => r.id === "e1")!;
expect(e1.note).toBeNull();
@@ -20,6 +20,7 @@ interface InventoryRow {
userId: string;
notes: string | null;
notesEncrypted: Uint8Array | null;
updatedAt?: Date;
}
const store = vi.hoisted(() => ({
@@ -99,16 +100,31 @@ vi.mock("@/lib/db", () => {
.map((r) => ({ id: r.id })),
findUnique: async (args: { where: { id: string } }) => {
const r = store.inventory.find((x) => x.id === args.where.id);
return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null;
return r
? {
notes: r.notes,
notesEncrypted: r.notesEncrypted,
updatedAt: r.updatedAt,
}
: null;
},
update: async (args: {
where: { id: string };
data: { notes: string | null; notesEncrypted: Uint8Array | null };
updateMany: async (args: {
where: { id: string; updatedAt?: Date };
data: {
notes: string | null;
notesEncrypted: Uint8Array | null;
updatedAt?: Date;
};
}) => {
const r = store.inventory.find((x) => x.id === args.where.id)!;
const r = store.inventory.find((x) => x.id === args.where.id);
if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) {
return { count: 0 };
}
r.notes = args.data.notes;
r.notesEncrypted = args.data.notesEncrypted;
return r;
// Prisma stamps now unless the write names the column.
r.updatedAt = args.data.updatedAt ?? new Date();
return { count: 1 };
},
},
};
@@ -148,7 +164,13 @@ beforeEach(() => {
},
];
store.inventory = [
{ id: "i1", userId: "u1", notes: "opened pen #2", notesEncrypted: null },
{
id: "i1",
userId: "u1",
notes: "opened pen #2",
notesEncrypted: null,
updatedAt: new Date("2026-01-12T10:00:00.000Z"),
},
{
id: "i2",
userId: "u1",
@@ -181,6 +203,8 @@ describe("runMedNotesEncryptionBackfillForUser", () => {
const i1 = store.inventory.find((r) => r.id === "i1")!;
expect(i1.notes).toBeNull();
expect(readNote(i1.notesEncrypted, null)).toBe("opened pen #2");
// A storage-only rewrite keeps the row's own updatedAt.
expect(i1.updatedAt).toEqual(new Date("2026-01-12T10:00:00.000Z"));
});
it("leaves note-less rows untouched and never writes a both-null content row", async () => {
@@ -7,12 +7,14 @@ interface MeasRow {
userId: string;
notes: string | null;
notesEncrypted: Uint8Array | null;
updatedAt?: Date;
}
interface MoodRow {
id: string;
userId: string;
note: string | null;
noteEncrypted: Uint8Array | null;
updatedAt?: Date;
}
const store = vi.hoisted(() => ({
@@ -44,16 +46,31 @@ vi.mock("@/lib/db", () => {
.map((r) => ({ id: r.id })),
findUnique: async (args: { where: { id: string } }) => {
const r = store.measurements.find((x) => x.id === args.where.id);
return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null;
return r
? {
notes: r.notes,
notesEncrypted: r.notesEncrypted,
updatedAt: r.updatedAt,
}
: null;
},
update: async (args: {
where: { id: string };
data: { notes: string | null; notesEncrypted: Uint8Array | null };
updateMany: async (args: {
where: { id: string; updatedAt?: Date };
data: {
notes: string | null;
notesEncrypted: Uint8Array | null;
updatedAt?: Date;
};
}) => {
const r = store.measurements.find((x) => x.id === args.where.id)!;
const r = store.measurements.find((x) => x.id === args.where.id);
if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) {
return { count: 0 };
}
r.notes = args.data.notes;
r.notesEncrypted = args.data.notesEncrypted;
return r;
// Prisma stamps now unless the write names the column.
r.updatedAt = args.data.updatedAt ?? new Date();
return { count: 1 };
},
},
moodEntry: {
@@ -69,16 +86,30 @@ vi.mock("@/lib/db", () => {
.map((r) => ({ id: r.id })),
findUnique: async (args: { where: { id: string } }) => {
const r = store.mood.find((x) => x.id === args.where.id);
return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null;
return r
? {
note: r.note,
noteEncrypted: r.noteEncrypted,
updatedAt: r.updatedAt,
}
: null;
},
update: async (args: {
where: { id: string };
data: { note: string | null; noteEncrypted: Uint8Array | null };
updateMany: async (args: {
where: { id: string; updatedAt?: Date };
data: {
note: string | null;
noteEncrypted: Uint8Array | null;
updatedAt?: Date;
};
}) => {
const r = store.mood.find((x) => x.id === args.where.id)!;
const r = store.mood.find((x) => x.id === args.where.id);
if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) {
return { count: 0 };
}
r.note = args.data.note;
r.noteEncrypted = args.data.noteEncrypted;
return r;
r.updatedAt = args.data.updatedAt ?? new Date();
return { count: 1 };
},
},
};
@@ -99,7 +130,13 @@ beforeEach(() => {
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY);
store.measurements = [
{ id: "m1", userId: "u1", notes: "felt dizzy", notesEncrypted: null },
{
id: "m1",
userId: "u1",
notes: "felt dizzy",
notesEncrypted: null,
updatedAt: new Date("2026-02-01T07:00:00.000Z"),
},
{ id: "m2", userId: "u1", notes: null, notesEncrypted: null }, // no note
{
id: "m3",
@@ -109,7 +146,13 @@ beforeEach(() => {
},
];
store.mood = [
{ id: "x1", userId: "u1", note: "rough night", noteEncrypted: null },
{
id: "x1",
userId: "u1",
note: "rough night",
noteEncrypted: null,
updatedAt: new Date("2026-02-03T22:00:00.000Z"),
},
{ id: "x2", userId: "u2", note: "other user", noteEncrypted: null },
];
fail.encryptThrows = false;
@@ -135,6 +178,10 @@ describe("runNoteEncryptionBackfillForUser", () => {
const x1 = store.mood.find((r) => r.id === "x1")!;
expect(x1.note).toBeNull();
expect(readNote(x1.noteEncrypted, null)).toBe("rough night");
// updatedAt is the sync cursor: a storage-only rewrite keeps it.
expect(m1.updatedAt).toEqual(new Date("2026-02-01T07:00:00.000Z"));
expect(x1.updatedAt).toEqual(new Date("2026-02-03T22:00:00.000Z"));
});
it("leaves note-less rows untouched and never writes a both-null content row", async () => {
+19 -9
View File
@@ -183,15 +183,23 @@ async function migrateConversationTitle(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.coachConversation.findUnique({
where: { id },
select: { title: true },
select: { title: true, updatedAt: true },
});
if (!fresh || fresh.title === null) return false;
// FAIL-CLOSED: a key error throws here and rolls the tx back.
await tx.coachConversation.update({
where: { id },
data: { titleEncrypted: encryptToBytes(fresh.title), title: null },
// `updatedAt` is carried, not stamped: the Coach panel orders and groups
// by it, and moving a title into ciphertext is not activity. Matching on
// it too means a turn that lands meanwhile wins; the row is retried on
// the next pass.
const { count } = await tx.coachConversation.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
titleEncrypted: encryptToBytes(fresh.title),
title: null,
updatedAt: fresh.updatedAt,
},
});
return true;
return count === 1;
});
}
@@ -224,14 +232,16 @@ async function migratePractitionerContact(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.practitioner.findUnique({
where: { id },
select: { phone: true, location: true },
select: { phone: true, location: true, updatedAt: true },
});
if (!fresh || (fresh.phone === null && fresh.location === null)) {
return false;
}
await tx.practitioner.update({
where: { id },
// Storage-only rewrite: the row keeps its own `updatedAt`.
const { count } = await tx.practitioner.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.phone !== null
? { phoneEncrypted: encryptNote(fresh.phone), phone: null }
: {}),
@@ -240,7 +250,7 @@ async function migratePractitionerContact(id: string): Promise<boolean> {
: {}),
},
});
return true;
return count === 1;
});
}
+10 -5
View File
@@ -120,15 +120,20 @@ async function migrateInventoryItemRow(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.medicationInventoryItem.findUnique({
where: { id },
select: { notes: true, notesEncrypted: true },
select: { notes: true, notesEncrypted: true, updatedAt: true },
});
if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false;
const encrypted = encryptNote(fresh.notes);
await tx.medicationInventoryItem.update({
where: { id },
data: { notesEncrypted: encrypted, notes: null },
// Storage-only rewrite: the row keeps its own `updatedAt`.
const { count } = await tx.medicationInventoryItem.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
notesEncrypted: encrypted,
notes: null,
updatedAt: fresh.updatedAt,
},
});
return true;
return count === 1;
});
}
+20 -10
View File
@@ -68,18 +68,24 @@ async function migrateMeasurementRow(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.measurement.findUnique({
where: { id },
select: { notes: true, notesEncrypted: true },
select: { notes: true, notesEncrypted: true, updatedAt: true },
});
// Already migrated (ciphertext present) or nothing to migrate.
if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false;
// FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the
// plaintext row untouched.
const encrypted = encryptNote(fresh.notes);
await tx.measurement.update({
where: { id },
data: { notesEncrypted: encrypted, notes: null },
// `updatedAt` is the sync cursor; a storage-only rewrite carries it so no
// client re-pulls a reading that did not change.
const { count } = await tx.measurement.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
notesEncrypted: encrypted,
notes: null,
updatedAt: fresh.updatedAt,
},
});
return true;
return count === 1;
});
}
@@ -91,15 +97,19 @@ async function migrateMoodRow(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.moodEntry.findUnique({
where: { id },
select: { note: true, noteEncrypted: true },
select: { note: true, noteEncrypted: true, updatedAt: true },
});
if (!fresh || fresh.noteEncrypted || fresh.note === null) return false;
const encrypted = encryptNote(fresh.note);
await tx.moodEntry.update({
where: { id },
data: { noteEncrypted: encrypted, note: null },
const { count } = await tx.moodEntry.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
noteEncrypted: encrypted,
note: null,
updatedAt: fresh.updatedAt,
},
});
return true;
return count === 1;
});
}
@@ -19,9 +19,21 @@ import type {
SourceDayAggregateRow,
} from "@/lib/measurements/day-aggregates";
import { userDayKey } from "@/lib/tz/format";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dayValue } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
/** `HH:MM:SS` of an instant on the wall clock of `timeZone`. */
function localClock(at: Date, timeZone: string): string {
const c = wallClockInTz(at, timeZone);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(c.hour)}:${pad(c.minute)}:${pad(c.second)}`;
}
function normaliseClock(clock: string): string {
return clock.length === 5 ? `${clock}:00` : clock;
}
export function foldDayAggregates(
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
opts: Omit<ReadDayAggregatesOptions, "userId" | "type">,
@@ -32,6 +44,13 @@ export function foldDayAggregates(
const t = r.measuredAt.getTime();
if (t < opts.since.getTime()) continue;
if (opts.until && t > opts.until.getTime()) continue;
if (
opts.upToLocalTime &&
localClock(r.measuredAt, opts.timeZone) >
normaliseClock(opts.upToLocalTime)
) {
continue;
}
if (
opts.valueRange &&
(r.value < opts.valueRange.min || r.value > opts.valueRange.max)
+16 -1
View File
@@ -63,6 +63,13 @@ export interface ReadDayAggregatesOptions {
since: Date;
/** Inclusive upper bound on `measuredAt`; omit for no upper bound. */
until?: Date;
/**
* Keep only readings taken at or before this local clock time
* (`HH:MM:SS`, in `timeZone`) on their own day. Comparing a day still in
* progress against earlier days needs the same hours on both sides: by
* ten in the morning, today's mean has seen breakfast and nothing after.
*/
upToLocalTime?: string;
/** IANA zone the day boundary is cut in. */
timeZone: string;
/** Rows outside `[min, max]` are dropped before the fold. */
@@ -73,13 +80,21 @@ export interface ReadDayAggregatesOptions {
db?: Pick<PrismaClient, "$queryRaw">;
}
/** `HH:MM` or `HH:MM:SS`, 24-hour. */
const LOCAL_CLOCK = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
export async function readDayAggregates(
opts: ReadDayAggregatesOptions,
): Promise<DayAggregateRow[]> {
const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC";
const until = opts.until
const untilInstant = opts.until
? Prisma.sql`AND m."measured_at" <= ${opts.until}`
: Prisma.empty;
const clock =
opts.upToLocalTime && LOCAL_CLOCK.test(opts.upToLocalTime)
? Prisma.sql`AND ((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone})::time <= ${opts.upToLocalTime}::time`
: Prisma.empty;
const until = Prisma.sql`${untilInstant} ${clock}`;
const range = opts.valueRange
? Prisma.sql`AND m."value" >= ${opts.valueRange.min} AND m."value" <= ${opts.valueRange.max}`
: Prisma.empty;
+32
View File
@@ -153,3 +153,35 @@ export function metricFractionDigits(type: string | null | undefined): number {
if (type && INTEGER_VALUED_MEASUREMENT_TYPES.has(type)) return 0;
return DEFAULT_METRIC_FRACTION_DIGITS;
}
/**
* The smallest half-width a personal band may have, in the metric's canonical
* unit: roughly the day-to-day wobble of the measurement itself.
*
* A median ± k·MAD band has no width at all when most days carry the same
* value (a device that reports a whole-number resting rate does this often),
* and then reads "your usual range is 61–61 bpm" while a reading of 62 counts
* as outside it. Below this width a band says nothing a single reading could
* not, so the band is widened to it. Metrics not listed take one step of
* their own display precision, which is the least that keeps the two edges
* apart on screen.
*/
const MIN_BAND_HALF_WIDTH: Readonly<Record<string, number>> = {
RESTING_HEART_RATE: 2, // bpm
PULSE: 3, // bpm
HEART_RATE_VARIABILITY: 3, // ms
RESPIRATORY_RATE: 0.5, // breaths/min
OXYGEN_SATURATION: 1, // %
BODY_TEMPERATURE: 0.2, // °C
SKIN_TEMPERATURE: 0.2, // °C
WRIST_TEMPERATURE: 0.2, // °C
BLOOD_PRESSURE_SYS: 4, // mmHg
BLOOD_PRESSURE_DIA: 3, // mmHg
BLOOD_GLUCOSE: 5, // mg/dL
WEIGHT: 0.3, // kg
};
export function minimumBandHalfWidth(type: string | null | undefined): number {
if (!type) return 0;
return MIN_BAND_HALF_WIDTH[type] ?? 10 ** -metricFractionDigits(type);
}
+17
View File
@@ -177,6 +177,23 @@ export const dailyDigestResponse = z
.describe(
"The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40.",
),
signalLine: z
.object({
headline: z
.string()
.nullable()
.describe(
"The top signal's headline, or null when the lead already talks about its metric.",
),
delta: z
.string()
.nullable()
.describe("The top signal's delta, when it has one."),
})
.nullable()
.describe(
"The muted line under an AI lead, decided on the server so every client shows the same one: the top signal minus what the lead already says. Null under a deterministic lead, without a top signal, or when the lead already says everything the signal would. Additive since v1.40.3.",
),
today: z
.array(todayFactSchema)
.max(MAX_TODAY_FACTS)
@@ -28,6 +28,11 @@ const healthStatusResponse = z
low: z.number(),
high: z.number(),
direction: z.enum(["above", "below"]),
basis: z
.enum(["day", "sameHours"])
.describe(
"What `low`/`high` describe: the usual whole day (`day`), or the usual range for this time of day (`sameHours`), used for a day still in progress whose mean moves with the hour (glucose). Additive since v1.40.3.",
),
}),
),
shifts: z.array(
@@ -0,0 +1,112 @@
/**
* Migration 0375 moves a Coach conversation's updated_at back to its newest
* message (real Postgres).
*
* The migration has already run on the empty test database when the
* container booted, so the test seeds the shapes it has to tell apart and
* runs the same SQL file again: a conversation a backfill bumped, one renamed
* within a minute of its last turn, one without messages, and a second pass
* that must change nothing.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getPrismaClient, truncateAllTables } from "./setup";
const REPAIR_SQL = readFileSync(
join(
process.cwd(),
"prisma/migrations/0375_coach_conversation_updated_at_repair/migration.sql",
),
"utf8",
);
const USER = "user-coach-updated-at-repair";
const LAST_TURN = new Date("2026-03-02T09:15:00.000Z");
const BACKFILL_RUN = new Date("2026-10-01T04:00:00.000Z");
const RENAMED = new Date("2026-03-02T09:15:40.000Z");
const EMPTY_STAMP = new Date("2026-09-30T12:00:00.000Z");
async function setUpdatedAt(id: string, at: Date) {
// A Prisma update would stamp now; set the column as the history left it.
await getPrismaClient().$executeRaw`
UPDATE "coach_conversations" SET "updated_at" = ${at} WHERE "id" = ${id}`;
}
async function stamps(): Promise<Record<string, string>> {
const rows = await getPrismaClient().coachConversation.findMany({
where: { userId: USER },
select: { id: true, updatedAt: true },
});
return Object.fromEntries(rows.map((r) => [r.id, r.updatedAt.toISOString()]));
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: "coach-repair",
email: "coach-repair@example.test",
timezone: "Europe/Berlin",
},
});
for (const id of ["bumped", "renamed", "empty"]) {
await prisma.coachConversation.create({
data: { id, userId: USER, titleEncrypted: encryptToBytes(id) },
});
}
for (const id of ["bumped", "renamed"]) {
await prisma.coachMessage.createMany({
data: [
{
conversationId: id,
role: "user",
encryptedContent: encryptToBytes("question"),
createdAt: new Date(LAST_TURN.getTime() - 30_000),
},
{
conversationId: id,
role: "assistant",
encryptedContent: encryptToBytes("answer"),
createdAt: LAST_TURN,
},
],
});
}
await setUpdatedAt("bumped", BACKFILL_RUN);
await setUpdatedAt("renamed", RENAMED);
await setUpdatedAt("empty", EMPTY_STAMP);
});
describe("migration 0375 — coach conversation updated_at repair", () => {
it("moves a bumped conversation back and leaves the others alone", async () => {
const changed = await getPrismaClient().$executeRawUnsafe(REPAIR_SQL);
expect(changed).toBe(1);
expect(await stamps()).toEqual({
bumped: LAST_TURN.toISOString(),
renamed: RENAMED.toISOString(),
empty: EMPTY_STAMP.toISOString(),
});
});
it("changes nothing on a second run", async () => {
const prisma = getPrismaClient();
await prisma.$executeRawUnsafe(REPAIR_SQL);
const after = await stamps();
expect(await prisma.$executeRawUnsafe(REPAIR_SQL)).toBe(0);
expect(await stamps()).toEqual(after);
});
it("reads the newest message through the conversation index", async () => {
const rows = await getPrismaClient().$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes
WHERE tablename = 'coach_messages'
AND indexname = 'coach_messages_conversation_id_created_at_idx'`;
expect(rows).toHaveLength(1);
expect(rows[0].indexdef).toMatch(/\(conversation_id, created_at\)/);
});
});
@@ -0,0 +1,77 @@
/**
* The Coach memory summary is bookkeeping, not activity (real Postgres).
*
* The Coach panel orders and groups conversations by `updatedAt`. The summary
* refresh rewrites a conversation row in the background, and as a Prisma
* `update` it stamped `updatedAt` with the run time, so a thread nobody had
* touched could surface under "Today". The write now goes through raw SQL;
* this pins that the summary lands (the ciphertext binds as `bytea`) and that
* the stamp stays where the last turn left it.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { refreshConversationSummary } from "@/lib/ai/coach/conversation-summary";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-coach-summary";
const LAST_TURN = new Date("2026-04-10T19:30:00.000Z");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER,
username: "coach-summary",
email: "coach-summary@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("coach memory summary", () => {
it("writes the summary and leaves updatedAt alone", async () => {
const prisma = getPrismaClient();
const conversation = await prisma.coachConversation.create({
data: {
userId: USER,
titleEncrypted: encryptToBytes("Training plan"),
},
});
await prisma.coachMessage.createMany({
data: Array.from({ length: 30 }, (_, i) => ({
conversationId: conversation.id,
role: i % 2 === 0 ? "user" : "assistant",
encryptedContent: encryptToBytes(`turn ${i}`),
createdAt: new Date(LAST_TURN.getTime() - (30 - i) * 60_000),
})),
});
// Created rows stamp now; set the stamp a last turn would have left.
await prisma.$executeRaw`
UPDATE "coach_conversations" SET "updated_at" = ${LAST_TURN}
WHERE "id" = ${conversation.id}`;
const now = new Date("2026-10-05T06:00:00.000Z");
const result = await refreshConversationSummary(conversation.id, USER, {
now,
runCompletion: async () => ({
kind: "ok",
content: "Training for a 10k, prefers morning runs.",
providerType: "admin-openai",
model: "test",
tokensUsed: 10,
}),
});
expect(result.status).toBe("generated");
const row = await prisma.coachConversation.findUniqueOrThrow({
where: { id: conversation.id },
});
expect(decryptFromBytes(row.summaryEncrypted!)).toBe(
"Training for a 10k, prefers morning runs.",
);
expect(row.summaryUpdatedAt).toEqual(now);
expect(row.summaryTurnCount).toBe(12);
expect(row.updatedAt).toEqual(LAST_TURN);
});
});
@@ -39,6 +39,7 @@ vi.mock("@/lib/db-compat", () => ({
const prisma = getPrismaClient();
const DENSE_USER = "dense-pulse-owner";
const PARITY_USER = "dense-parity-owner";
const CLOCK_USER = "dense-clock-owner";
const DAYS = 91;
const ROWS = DAYS * 24 * 60;
const NOW = new Date("2026-09-20T18:00:00.000Z");
@@ -232,4 +233,53 @@ describe("pulse status over a dense stream (#1023)", () => {
// Sanity: the impossible value was dropped and the edge row split off.
expect(sql.reduce((n, r) => n + r.n, 0)).toBe(5);
});
it("the local-clock cut agrees with the in-memory fold on real Postgres", async () => {
// A half-hour zone, so a cut on the UTC clock would land 30 minutes off.
const tz = "Asia/Kolkata";
await prisma.user.create({
data: { id: CLOCK_USER, username: CLOCK_USER, timezone: tz },
});
const days = [
new Date("2026-09-08T12:00:00Z"),
new Date("2026-09-09T12:00:00Z"),
new Date("2026-09-10T12:00:00Z"),
];
const rows = days.flatMap((d, i) => [
{ at: localHmAsUtc(d, tz, 7, 0), value: 85 + i },
{ at: localHmAsUtc(d, tz, 9, 59), value: 120 + i },
{ at: localHmAsUtc(d, tz, 10, 1), value: 160 + i }, // after the cut
{ at: localHmAsUtc(d, tz, 19, 0), value: 150 + i }, // after the cut
]);
await prisma.measurement.createMany({
data: rows.map((r, i) => ({
id: `clock-${i}`,
userId: CLOCK_USER,
type: "BLOOD_GLUCOSE" as const,
value: r.value,
unit: "mg/dL",
source: "MANUAL" as const,
measuredAt: r.at,
})),
});
const opts = {
since: new Date(NOW.getTime() - 400 * 86_400_000),
until: NOW,
timeZone: tz,
upToLocalTime: "10:00:00",
};
const sql = await readDayAggregates({
userId: CLOCK_USER,
type: "BLOOD_GLUCOSE",
...opts,
});
const memory = foldDayAggregates(
rows.map((r) => ({ measuredAt: r.at, value: r.value })),
opts,
);
expect(sql).toEqual(memory);
// Two readings per day survive the cut, the two after it do not.
expect(sql.map((r) => r.n)).toEqual([2, 2, 2]);
expect(sql.map((r) => r.max)).toEqual([120, 121, 122]);
});
});
@@ -86,6 +86,7 @@ describe("key rotation over the v1.39.4 columns", () => {
name: "Praxis Nord",
phoneEncrypted: encryptNote("+49 30 1234567"),
locationEncrypted: encryptNote("Hauptstr. 1"),
updatedAt: new Date("2026-05-04T10:00:00.000Z"),
},
});
const startedAt = new Date("2026-09-01T06:00:00.000Z");
@@ -136,6 +137,10 @@ describe("key rotation over the v1.39.4 columns", () => {
}
expect(readNote(practitioner.phoneEncrypted, null)).toBe("+49 30 1234567");
expect(readNote(practitioner.locationEncrypted, null)).toBe("Hauptstr. 1");
// Re-sealing under a new key is not an edit: the row keeps its stamp.
expect(practitioner.updatedAt).toEqual(
new Date("2026-05-04T10:00:00.000Z"),
);
const route = await prisma.workoutRoute.findUniqueOrThrow({
where: { id: "rt-1" },
@@ -71,8 +71,15 @@ describe("free-text encryption backfill (real Postgres)", () => {
await Promise.all([seedUser("ft-a"), seedUser("ft-b"), seedUser("ft-c")]);
// ft-a: a legacy readable title and a sealed one.
// Last active months ago: the backfill must not make it look recent.
const lastActive = new Date("2026-03-02T09:15:00.000Z");
await prisma.coachConversation.create({
data: { id: "conv-legacy", userId: "ft-a", title: "Legacy title" },
data: {
id: "conv-legacy",
userId: "ft-a",
title: "Legacy title",
updatedAt: lastActive,
},
});
await prisma.coachConversation.create({
data: {
@@ -150,6 +157,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
{ id: "conv-legacy", readable: null, text: "Legacy title" },
{ id: "conv-sealed", readable: null, text: "Sealed title" },
]);
// The Coach panel orders and groups by updatedAt; sealing a title is not
// activity, so the stamp is the one the conversation already had.
expect(conversations[0].updatedAt).toEqual(lastActive);
const entries = await prisma.customMetricEntry.findMany({
where: { userId: "ft-b" },
orderBy: { id: "asc" },
@@ -41,6 +41,7 @@ describe("note-encryption backfill (real Postgres)", () => {
unit: "kg",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
notes: "felt dizzy after",
updatedAt: new Date("2026-05-16T08:05:00.000Z"),
},
});
const noNote = await prisma.measurement.create({
@@ -62,6 +63,7 @@ describe("note-encryption backfill (real Postgres)", () => {
score: 3,
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
note: "rough night",
updatedAt: new Date("2026-05-16T08:06:00.000Z"),
},
});
@@ -85,6 +87,11 @@ describe("note-encryption backfill (real Postgres)", () => {
const x = await prisma.moodEntry.findUnique({ where: { id: mood.id } });
expect(x?.note).toBeNull();
expect(readNote(x?.noteEncrypted ?? null, null)).toBe("rough night");
// updatedAt is the sync cursor: moving a note into ciphertext changes
// nothing a client reads, so neither row takes a new stamp.
expect(m?.updatedAt).toEqual(new Date("2026-05-16T08:05:00.000Z"));
expect(x?.updatedAt).toEqual(new Date("2026-05-16T08:06:00.000Z"));
});
it("is idempotent: a second run migrates zero rows and preserves the data", async () => {