Merge branch 'fix/v1437-r2-data' into release/v1.43.7

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:11:09 +02:00
commit cd0bbec66a
113 files changed
+2367 -698

No files matched your search

+66 -16
View File
@@ -4207,11 +4207,13 @@ paths:
With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over
those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings)
are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way
over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0
with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers
for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as
asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value
applied.
over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its
naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone
in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps
carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=`
answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes
`limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the
value applied.
parameters:
- in: query
name: type
@@ -4732,16 +4734,18 @@ paths:
source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals,
the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the
local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date`
omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut.
A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type."
omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over
and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is
answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new
measurement type."
parameters:
- in: query
name: date
schema:
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.
responses:
"200":
description: Reconstructed night.
@@ -21432,7 +21436,8 @@ paths:
per day with a count, rather than one per request. `retentionDays` rides the response because audit history is
purged on a schedule the operator configures: a view that showed the rows without the window would imply a
completeness it does not have, and clients must state the number the server sends rather than assume the 365-day
default. Not delegable: a grant is not an introduction to the other people in the household."
default. Whether the list stopped at its row ceiling is `meta.truncated` (it was a `truncated` field in `data`
before v1.43.7). Not delegable: a grant is not an introduction to the other people in the household."
responses:
"200":
description: The activity, and how far back it can see.
@@ -46284,6 +46289,23 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
mainAsleepMinutes:
description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
day's total, the main night plus its naps.
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
napCount:
description: Naps on the same wake day.
type: integer
minimum: 0
maximum: 9007199254740991
napAsleepMinutes:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
required:
- id
- type
@@ -47108,6 +47130,23 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
mainAsleepMinutes:
description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
day's total, the main night plus its naps.
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
napCount:
description: Naps on the same wake day.
type: integer
minimum: 0
maximum: 9007199254740991
napAsleepMinutes:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
required:
- id
- type
@@ -47289,6 +47328,23 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
mainAsleepMinutes:
description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
day's total, the main night plus its naps.
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
napCount:
description: Naps on the same wake day.
type: integer
minimum: 0
maximum: 9007199254740991
napAsleepMinutes:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
status:
type: string
enum:
@@ -81335,15 +81391,9 @@ components:
description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by
default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is
bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.
truncated:
type: boolean
description: True when the read reached its row ceiling, so `entries` is the most recent activity inside the window
rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest
row read as the beginning. False means the list is everything inside the window.
required:
- entries
- retentionDays
- truncated
additionalProperties: false
description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention.
RecordActivityEntry:
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Hier stehen die letzten {count}; ältere Einträge sind nicht aufgeführt.",
"save": "Speichern",
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
"cancel": "Abbrechen",
@@ -845,7 +846,7 @@
"nextPage": "Nächste Seite",
"dailyTotalCaption": "({count} Einzelwerte)",
"openMetricInsights": "{type}-Auswertung öffnen",
"sleepNapCaption": "+{count} Nickerchen",
"sleepNapCaption": "inkl. {count} Nickerchen",
"sleepAwakeningsCaption": "{count} Wachphasen",
"expandDay": "Einzelwerte dieses Tages anzeigen",
"collapseDay": "Einzelwerte dieses Tages ausblenden",
@@ -12327,8 +12328,7 @@
"loadError": "Die Aktivität konnte nicht geladen werden.",
"opened": "{name} hat deine Akte {count}× geöffnet",
"acted": "{name} hat etwas in deiner Akte geändert",
"deletedAccount": "Ein gelöschtes Konto",
"truncated": "Diese Liste zeigt die neueste Aktivität, nicht die gesamte."
"deletedAccount": "Ein gelöschtes Konto"
},
"delegate": {
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Showing the latest {count}; older entries are not listed here.",
"save": "Save",
"conflictReloaded": "Reloaded from the server. Please make your change again.",
"cancel": "Cancel",
@@ -845,7 +846,7 @@
"nextPage": "Next page",
"dailyTotalCaption": "({count} samples)",
"openMetricInsights": "Open {type} insights",
"sleepNapCaption": "+{count} nap(s)",
"sleepNapCaption": "incl. {count} nap(s)",
"sleepAwakeningsCaption": "{count} awakening(s)",
"expandDay": "Show samples for this day",
"collapseDay": "Hide samples for this day",
@@ -12327,8 +12328,7 @@
"loadError": "The activity could not be loaded.",
"opened": "{name} opened your record {count}×",
"acted": "{name} made a change in your record",
"deletedAccount": "A deleted account",
"truncated": "This list shows the most recent activity, not all of it."
"deletedAccount": "A deleted account"
},
"delegate": {
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Se muestran los últimos {count}; las entradas anteriores no aparecen aquí.",
"save": "Guardar",
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
"cancel": "Cancelar",
@@ -845,7 +846,7 @@
"nextPage": "Página siguiente",
"dailyTotalCaption": "({count} muestras)",
"openMetricInsights": "Abrir análisis de {type}",
"sleepNapCaption": "+{count} siesta(s)",
"sleepNapCaption": "incl. {count} siesta(s)",
"sleepAwakeningsCaption": "{count} despertar(es)",
"expandDay": "Mostrar las muestras de este día",
"collapseDay": "Ocultar las muestras de este día",
@@ -12327,8 +12328,7 @@
"loadError": "No se pudo cargar la actividad.",
"opened": "{name} abrió tu historial {count}×",
"acted": "{name} hizo un cambio en tu historial",
"deletedAccount": "Una cuenta eliminada",
"truncated": "Esta lista muestra la actividad más reciente, no toda."
"deletedAccount": "Una cuenta eliminada"
},
"delegate": {
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Seuls les {count} derniers sont affichés ; les entrées plus anciennes ne figurent pas ici.",
"save": "Enregistrer",
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
"cancel": "Annuler",
@@ -845,7 +846,7 @@
"nextPage": "Page suivante",
"dailyTotalCaption": "({count} échantillons)",
"openMetricInsights": "Ouvrir l'analyse {type}",
"sleepNapCaption": "+{count} sieste(s)",
"sleepNapCaption": "dont {count} sieste(s)",
"sleepAwakeningsCaption": "{count} réveil(s)",
"expandDay": "Afficher les échantillons de ce jour",
"collapseDay": "Masquer les échantillons de ce jour",
@@ -12327,8 +12328,7 @@
"loadError": "L'activité n'a pas pu être chargée.",
"opened": "{name} a ouvert votre dossier {count}×",
"acted": "{name} a fait une modification dans votre dossier",
"deletedAccount": "Un compte supprimé",
"truncated": "Cette liste montre l'activité la plus récente, pas la totalité."
"deletedAccount": "Un compte supprimé"
},
"delegate": {
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Sono mostrati gli ultimi {count}; le voci più vecchie non sono elencate qui.",
"save": "Salva",
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
"cancel": "Annulla",
@@ -845,7 +846,7 @@
"nextPage": "Pagina successiva",
"dailyTotalCaption": "({count} campioni)",
"openMetricInsights": "Apri l'analisi {type}",
"sleepNapCaption": "+{count} pisolino/i",
"sleepNapCaption": "incl. {count} pisolino/i",
"sleepAwakeningsCaption": "{count} risveglio/i",
"expandDay": "Mostra i campioni di questa giornata",
"collapseDay": "Nascondi i campioni di questa giornata",
@@ -12327,8 +12328,7 @@
"loadError": "Non è stato possibile caricare l'attività.",
"opened": "{name} ha aperto la tua cartella {count}×",
"acted": "{name} ha fatto una modifica nella tua cartella",
"deletedAccount": "Un account eliminato",
"truncated": "Questo elenco mostra l'attività più recente, non tutta."
"deletedAccount": "Un account eliminato"
},
"delegate": {
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "최근 {count}개만 표시되며, 이전 항목은 여기에 나오지 않습니다.",
"save": "저장",
"conflictReloaded": "서버에서 다시 불러왔어요. 변경 내용을 다시 입력해 주세요.",
"cancel": "취소",
@@ -845,7 +846,7 @@
"nextPage": "다음 쪽",
"dailyTotalCaption": "({count}개 샘플)",
"openMetricInsights": "{type} 인사이트 열기",
"sleepNapCaption": "+낮잠 {count}회",
"sleepNapCaption": "낮잠 {count}회 포함",
"sleepAwakeningsCaption": "깬 횟수 {count}회",
"expandDay": "이 날의 샘플 보기",
"collapseDay": "이 날의 샘플 숨기기",
@@ -12327,8 +12328,7 @@
"loadError": "활동 내역을 불러오지 못했어요.",
"opened": "{name} 님이 내 기록을 {count}번 열었어요",
"acted": "{name} 님이 내 기록을 수정했어요",
"deletedAccount": "삭제된 계정",
"truncated": "이 목록은 최근 활동만 보여줘요. 전부는 아니에요."
"deletedAccount": "삭제된 계정"
},
"delegate": {
"addOnlyNote": "이 기록에 추가할 수 있어요. 수정과 삭제는 {name} 님만 할 수 있어요."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Alleen de laatste {count} worden getoond; oudere items staan hier niet.",
"save": "Opslaan",
"conflictReloaded": "Opnieuw geladen van de server. Voer je wijziging opnieuw uit.",
"cancel": "Annuleren",
@@ -845,7 +846,7 @@
"nextPage": "Volgende pagina",
"dailyTotalCaption": "({count} metingen)",
"openMetricInsights": "Inzichten voor {type} openen",
"sleepNapCaption": "+{count} dutje(s)",
"sleepNapCaption": "incl. {count} dutje(s)",
"sleepAwakeningsCaption": "{count} keer wakker",
"expandDay": "Metingen van deze dag tonen",
"collapseDay": "Metingen van deze dag verbergen",
@@ -12327,8 +12328,7 @@
"loadError": "De activiteit kon niet worden geladen.",
"opened": "{name} heeft je dossier {count}× geopend",
"acted": "{name} heeft iets gewijzigd in je dossier",
"deletedAccount": "Een verwijderd account",
"truncated": "Deze lijst toont de meest recente activiteit, niet alles."
"deletedAccount": "Een verwijderd account"
},
"delegate": {
"addOnlyNote": "Je kunt iets toevoegen aan dit dossier. Registraties wijzigen of verwijderen kan alleen {name}."
+3 -3
View File
@@ -1,5 +1,6 @@
{
"common": {
"listCapped": "Widać tylko ostatnie {count}; starsze wpisy nie są tu wymienione.",
"save": "Zapisz",
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
"cancel": "Anuluj",
@@ -845,7 +846,7 @@
"nextPage": "Następna strona",
"dailyTotalCaption": "({count} pomiarów)",
"openMetricInsights": "Otwórz analizę {type}",
"sleepNapCaption": "+{count} drzemka/i",
"sleepNapCaption": "w tym {count} drzemka/i",
"sleepAwakeningsCaption": "{count} przebudzenia",
"expandDay": "Pokaż pomiary z tego dnia",
"collapseDay": "Ukryj pomiary z tego dnia",
@@ -12327,8 +12328,7 @@
"loadError": "Nie udało się wczytać aktywności.",
"opened": "{name} otworzył twoją kartę {count}×",
"acted": "{name} wprowadził zmianę w twojej karcie",
"deletedAccount": "Usunięte konto",
"truncated": "Ta lista pokazuje najnowszą aktywność, a nie całą."
"deletedAccount": "Usunięte konto"
},
"delegate": {
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
+4 -2
View File
@@ -329,7 +329,8 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
height_cm, date_of_birth, gender, timezone,
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
codex_connected_at, codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
insights_exclude_metrics,
global_excluded_injection_sites,
telegram_bot_token, telegram_chat_id, telegram_enabled,
withings_client_id_encrypted, withings_client_secret_encrypted,
@@ -361,7 +362,8 @@ readonly USERS_SELECT="id, username, email, password_hash,
NULL::timestamptz AS codex_token_expires_at,
NULL::timestamptz AS codex_connected_at,
'disconnected' AS codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
insights_exclude_metrics,
global_excluded_injection_sites,
NULL::text AS telegram_bot_token,
NULL::text AS telegram_chat_id,
+10 -3
View File
@@ -88,6 +88,7 @@ import {
getKeyFingerprint,
} from "../src/lib/crypto";
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher";
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
@@ -3343,7 +3344,8 @@ async function seed() {
const bakedProvider = "anthropic";
// ── Comprehensive insight + daily briefing ──
// Stored on users.insights_cached_text (JSON). The GET read parses it and
// Stored sealed on users.insights_cached_text_encrypted (JSON). The GET
// read opens and parses it and
// returns it as-is; setting insights_cached_at = NOW() keeps it inside the
// 24 h freshness window for the dashboard-snapshot briefing path. The shape
// is the union of insightResultSchema (summary / findings / correlations /
@@ -3527,14 +3529,19 @@ async function seed() {
await client.query(
`UPDATE users
SET insights_cached_text = $2,
SET insights_cached_text_encrypted = $2,
insights_cached_text = NULL,
insights_cached_at = NOW(),
insights_snapshot_hash = $3,
insights_briefing_reroll_date = $4
WHERE id = $1`,
[
userId,
JSON.stringify(comprehensiveInsight),
// Sealed like every write since v1.43.7; the app never reads the
// readable column while a ciphertext is there.
Buffer.from(
sealInsightsCachedText(JSON.stringify(comprehensiveInsight)),
),
// A stable fingerprint. The 24 h cache window keeps the row fresh on
// its own; this value just has to be present + non-null so a
// hypothetical regeneration would treat the snapshot as known. The
@@ -14,6 +14,12 @@
* the one cell; `computeSummariesSlice` is its builder and is called nowhere
* else in production code.
*
* The Prisma spelling of the same question is `measurement.groupBy` by type
* with nothing but `_max` (or nothing at all, a presence probe) and no time
* window: it groups every live row the account has to answer a question one
* index probe per type answers. Grouped reads that count, sum or average, or
* that are bounded in time, ask something else and pass.
*
* Comments are stripped before matching, and the planted cases prove each
* matcher finds what it claims to.
*/
@@ -37,6 +43,36 @@ const LATEST_PER_TYPE = /DISTINCT\s+ON\s*\(\s*(?:\w+\s*\.\s*)?"?type"?\s*\)/i;
const SLICE_BUILDER_CALL = /\bcomputeSummariesSlice\s*\(/;
/** Grouped presence reads that ask something the per-type probe cannot. */
const GROUPBY_ALLOWED: Record<string, string> = {
"lib/measurements/import-apple-health-export.ts":
"Which types hold a MANUAL reading: a source-filtered presence the per-type probe does not take.",
"lib/jobs/insight-pregenerate.ts":
"Which types exist at all, with no type list to drive a per-type probe; a nightly job, not a request path.",
};
/** Each `measurement.groupBy(...)` argument that asks only latest or presence. */
function latestOnlyGroupBys(code: string): string[] {
const out: string[] = [];
for (const m of code.matchAll(/\.measurement\s*\.\s*groupBy\s*\(/g)) {
let i = m.index + m[0].length;
let depth = 1;
while (depth > 0 && i < code.length) {
if (code[i] === "(") depth += 1;
else if (code[i] === ")") depth -= 1;
i += 1;
}
const arg = code.slice(m.index + m[0].length, i - 1);
const byType = /by:\s*\[\s*"type"\s*\]/.test(arg);
const asksMore = /\b_(?:count|sum|avg|min)\b/.test(arg);
const windowed = /\bmeasuredAt\s*:/.test(
arg.replace(/_max:\s*\{[^}]*\}/, ""),
);
if (byType && !asksMore && !windowed) out.push(arg.replace(/\s+/g, " "));
}
return out;
}
function productionFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
@@ -71,6 +107,49 @@ describe("latest-per-type reads", () => {
expect(offenders).toEqual([]);
});
it("the groupBy matcher finds a latest-only or presence-only read", () => {
expect(
latestOnlyGroupBys(
`prisma.measurement.groupBy({ by: ["type"], where: { userId }, _max: { measuredAt: true } })`,
),
).toHaveLength(1);
expect(
latestOnlyGroupBys(
`prisma.measurement\n .groupBy({ by: ["type"], where: { userId, deletedAt: null } })`,
),
).toHaveLength(1);
expect(
latestOnlyGroupBys(
`prisma.measurement.groupBy({ by: ["type"], where: { userId }, _count: { _all: true }, _max: { measuredAt: true } })`,
),
).toEqual([]);
expect(
latestOnlyGroupBys(
`prisma.measurement.groupBy({ by: ["type"], where: { userId, measuredAt: { gte: since } }, _max: { measuredAt: true } })`,
),
).toEqual([]);
});
it("no production read groups every row for the latest per type", () => {
const files = productionFiles();
const grouped = files.filter((file) =>
/\.measurement\s*\.\s*groupBy\s*\(/.test(
readFileSync(join(SRC, file), "utf8"),
),
);
// The tree groups measurements in a dozen places; finding none means
// the walk read the wrong tree.
expect(grouped.length).toBeGreaterThanOrEqual(8);
const offenders = files
.filter((file) => !(file in GROUPBY_ALLOWED))
.flatMap((file) =>
latestOnlyGroupBys(
stripComments(readFileSync(join(SRC, file), "utf8")),
).map((arg) => `${file}: ${arg.slice(0, 100)}`),
);
expect(offenders).toEqual([]);
});
it("the summaries slice is built only behind its cache cell", () => {
const callers = productionFiles().filter((file) =>
SLICE_BUILDER_CALL.test(
@@ -0,0 +1,77 @@
/**
* Structural guard: a least-squares line is fitted in `lib/stats/linear-fit`.
*
* Six readers fitted one by hand, four with the determinant form
* `n·Σxy − Σx·Σy` and two with centred sums, and two charts carried the same
* function twice. The matcher flags either form of the slope outside the
* implementation. Comments are stripped first; the planted cases prove the
* matcher fires.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const ALLOWED: Record<string, string> = {
"lib/stats/linear-fit.ts": "The implementation.",
};
const HAND_ROLLED_SLOPE =
/\bn\s*\*\s*sum_?xy\s*-\s*sum_?x\s*\*\s*sum_?y\b|\bsxy\s*\/\s*sxx\b|\bsumXy\s*-\s*\(\s*sumX\s*\*\s*sumY\s*\)\s*\/\s*n\b/i;
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
describe("one least-squares line", () => {
it("the matcher fires on a hand-rolled slope", () => {
expect(
HAND_ROLLED_SLOPE.test(
"const slope = (n * sumXY - sumX * sumY) / denominator;",
),
).toBe(true);
expect(HAND_ROLLED_SLOPE.test("const slope = sxy / sxx;")).toBe(true);
expect(
HAND_ROLLED_SLOPE.test("const sxy = sumXy - (sumX * sumY) / n;"),
).toBe(true);
expect(HAND_ROLLED_SLOPE.test("const fit = linearFit(xs, ys);")).toBe(
false,
);
});
it("no file outside the implementation fits its own", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
const offenders = files.filter(
(file) =>
!(file in ALLOWED) &&
HAND_ROLLED_SLOPE.test(
stripComments(readFileSync(join(SRC, file), "utf8")),
),
);
expect(offenders).toEqual([]);
// The six readers now call the implementation: a sweep that finds fewer
// call sites read the wrong tree or lost one.
const callers = files.filter((file) =>
/\b(?:linearFit|fitFromSums|trendLineByTime)\s*\(/.test(
stripComments(readFileSync(join(SRC, file), "utf8")),
),
);
expect(callers.length).toBeGreaterThanOrEqual(7);
// The implementation itself matches, so the sweep read real files.
expect(
HAND_ROLLED_SLOPE.test(
stripComments(
readFileSync(join(SRC, "lib/stats/linear-fit.ts"), "utf8"),
),
),
).toBe(true);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Structural guard: every list the server caps has a client that reads the
* cap.
*
* A capped list says so on the envelope (`apiListSuccess`, `meta.truncated`
* / `meta.limit`). The web client read only `data` (`apiGet`), so the signal
* reached nobody: twelve routes sent it and no surface showed it. A capped
* route is now bound to a reader, a production file outside the API tree
* that fetches the route's path through `apiGetList` / `apiPostList`, the
* one client reader of the cap, which a surface pairs with `ListCapNotice`.
*
* The routes are derived from the tree (every route file that answers with
* `apiListSuccess`), never listed by hand, so a newly capped route fails here
* until something reads its cap. Comments are stripped first; the planted
* cases prove the matchers fire.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** Capped routes with no list on screen, and why. */
const NO_LIST_ON_SCREEN: Record<string, string> = {
"/api/personal-records":
"The web reads it for one decorative badge (a record set in the last 30 days), not as a list; the native client owns the list.",
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** `app/api/a/[id]/b/route.ts` → `/api/a/[id]/b`. */
function routePathOf(file: string): string {
return `/${file.replace(/^app\//, "").replace(/\/route\.tsx?$/, "")}`;
}
/** A matcher for the route's path as a client spells it. */
function clientPathPattern(route: string): RegExp {
const body = route
.split("/")
.map((seg) =>
/^\[.+\]$/.test(seg)
? "\\$\\{[^}]+\\}"
: seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
)
.join("/");
return new RegExp(
`\\bapi(?:Get|Post)List\\s*<[\\s\\S]{0,200}?>\\s*\\(\\s*["\`]${body}(?:[?"\`])|\\bapi(?:Get|Post)List\\s*\\(\\s*["\`]${body}(?:[?"\`])`,
);
}
describe("capped lists have a reader", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
const read = (file: string) =>
stripComments(readFileSync(join(SRC, file), "utf8"));
const cappedRoutes = files
.filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f))
.filter((f) => /\bapiListSuccess\s*\(/.test(read(f)))
.map(routePathOf)
.sort();
const clients = files
.filter((f) => !f.startsWith("app/api/"))
.map((f) => read(f));
it("the matcher binds a client spelling to its route", () => {
expect(
clientPathPattern("/api/medications/[id]/side-effects").test(
"apiGetList<{ items: Row[] }>(`/api/medications/${medicationId}/side-effects?from=1`)",
),
).toBe(true);
expect(
clientPathPattern("/api/allergies").test(
'apiGetList<AllergyDTO[]>("/api/allergies")',
),
).toBe(true);
// A plain `apiGet` reads `data` only and does not count.
expect(
clientPathPattern("/api/allergies").test(
'apiGet<AllergyDTO[]>("/api/allergies")',
),
).toBe(false);
// A longer path that shares the prefix does not count either.
expect(
clientPathPattern("/api/allergies").test(
'apiGetList<AllergyDTO>("/api/allergies/x")',
),
).toBe(false);
});
it("finds the capped routes", () => {
expect(cappedRoutes.length).toBeGreaterThanOrEqual(12);
});
it("binds every capped route to a client that reads its cap", () => {
const unread = cappedRoutes.filter(
(route) =>
!(route in NO_LIST_ON_SCREEN) &&
!clients.some((code) => clientPathPattern(route).test(code)),
);
expect(unread).toEqual([]);
});
it("keeps the exemptions to routes that are still capped", () => {
for (const route of Object.keys(NO_LIST_ON_SCREEN)) {
expect(cappedRoutes).toContain(route);
}
});
});
+28 -7
View File
@@ -1,11 +1,14 @@
/**
* Structural guard: a provider client rounds an imported value through
* `importValue` (`src/lib/integrations/import-value.ts`) and nothing else.
* Structural guard: a provider client or a file importer rounds an imported
* value through `importValue` (`src/lib/integrations/import-value.ts`) and
* nothing else.
*
* Each client used to keep its own `parseFloat(n.toFixed(2))`. Five copies
* of one rule drift apart, and `toFixed` mis-rounds halves on the double
* below the decimal. A `toFixed` or a private `round2` in a provider
* directory is the class coming back.
* below the decimal. The Health Connect importer had the other spelling,
* `Math.round(x * 1000) / 1000`, in three places and stored its unit
* factors' noise unrounded everywhere else. A `toFixed`, a private `round2`
* or a scaled `Math.round` in these directories is the class coming back.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -25,8 +28,18 @@ const PROVIDER_DIRS = [
"strava",
"whoop",
"withings",
"import",
];
const ROUNDING = /\.toFixed\s*\(|\b(?:function|const|let)\s+round2\b/;
const ROUNDING =
/\.toFixed\s*\(|\b(?:function|const|let)\s+round2\b|Math\.round\s*\([^;]*\*\s*(10+)\s*\)\s*\/\s*\1\b/;
/** Files that round for display, not on the way in, and say so. */
const ALLOWED: Record<string, string> = {
"dawarich/movement.ts":
"Formats a stored distance to one decimal in the account's unit for the day view; nothing is stored.",
"import/unzip-export-xml.ts":
"Prints a compression ratio into an error message.",
};
function strip(text: string): string {
return text
@@ -47,8 +60,10 @@ describe("provider clients round through importValue", () => {
});
it("finds no rounding of their own", () => {
const offenders = files.filter((f) =>
ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))),
const offenders = files.filter(
(f) =>
!(f in ALLOWED) &&
ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))),
);
expect(offenders).toEqual([]);
});
@@ -57,6 +72,12 @@ describe("provider clients round through importValue", () => {
expect(ROUNDING.test("const v = parseFloat(n.toFixed(2));")).toBe(true);
expect(ROUNDING.test("function round2(n: number) {}")).toBe(true);
expect(ROUNDING.test("const round2 = (n: number) => n;")).toBe(true);
expect(ROUNDING.test("const a = Math.round(e.amount * 1000) / 1000;")).toBe(
true,
);
expect(ROUNDING.test("value: Math.round(mean * 10) / 10,")).toBe(true);
expect(ROUNDING.test("value: importValue(n)")).toBe(false);
expect(ROUNDING.test("Math.round((end - start) / 1000)")).toBe(false);
expect(ROUNDING.test("Math.round(mmol * MGDL_PER_MMOL)")).toBe(false);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* Structural guard: a read that feeds analysis or a model prompt never sees a
* deleted row.
*
* Several record types delete softly: the row stays with `deletedAt` set so
* sync can propagate the delete and undo can bring it back. Four per-metric
* status snapshots read mood entries with `where: { userId }` alone, so an
* entry the person had deleted kept shaping the text written about them, and
* the blood-pressure snapshot did the same with measurements.
*
* The models are derived from the schema (every model with a `deletedAt`
* column), not listed by hand, so a model that gains soft delete is covered
* the day it does. The scope is every directory whose output is analysis,
* a score, an insight, or model input. A read passes when its argument names
* `deletedAt`, or when it filters through a local object that does. The
* entries below read tombstones on purpose and say why. Comments are
* stripped first; the planted cases prove the matcher fires.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const SCOPES = [
"lib/insights/",
"lib/analytics/",
"lib/ai/",
"lib/rollups/",
"lib/doctor-report/",
"lib/day/",
"lib/timeline/",
"lib/targets/",
"lib/gamification/",
"lib/mood/",
"app/api/insights/",
"app/api/analytics/",
"app/api/dashboard/",
];
/** `model@file` → why this read includes deleted rows. */
const ALLOWED: Record<string, string> = {
"measurement@lib/rollups/measurement-rollups.ts":
"Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.",
"moodEntry@lib/rollups/mood-rollups.ts":
"Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.",
"coachFact@lib/ai/coach/memory/trail-read.ts":
"Withholds medication texts from the recall trail; a deleted fact still withholds, which errs toward saying less.",
"coachFact@lib/ai/coach/memory/remember.ts":
"Answer-once check on the message that proposed the fact, inside its lock.",
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
function tombstonedModels(): string[] {
const schema = readFileSync(
join(process.cwd(), "prisma/schema.prisma"),
"utf8",
);
const models = new Set<string>();
let current: string | null = null;
for (const line of schema.split("\n")) {
const head = /^model\s+(\w+)\s*\{/.exec(line);
if (head) current = head[1];
else if (/^\}/.test(line)) current = null;
else if (current && /^\s+deletedAt\s+DateTime\?/.test(line)) {
models.add(current[0].toLowerCase() + current.slice(1));
}
}
return [...models].sort();
}
/** The local object a read filters through carries `deletedAt`. */
function filtersThroughLiveObject(
code: string,
arg: string,
at: number,
): boolean {
const names = [...arg.matchAll(/(?:where:\s*|\.\.\.)(\w+)/g)].map(
(m) => m[1],
);
if (/(?:^|[{,])\s*where\s*[,}]/.test(arg)) names.push("where");
return names.some((name) => {
// The nearest declaration above the read is the one it uses.
const decl = new RegExp(
`(?:const|let)\\s+${name}\\b[^=]*=\\s*\\{([\\s\\S]{0,600}?)\\};`,
"g",
);
const before = [...code.slice(0, at).matchAll(decl)].at(-1);
return before !== undefined && /\bdeletedAt\b/.test(before[1]);
});
}
export function unfilteredReads(
code: string,
models: string[],
): { model: string; arg: string }[] {
const re = new RegExp(
`\\.(${models.join("|")})\\s*\\.\\s*(findMany|findFirst|count|groupBy|aggregate)\\s*\\(`,
"g",
);
const out: { model: string; arg: string }[] = [];
for (const m of code.matchAll(re)) {
let i = m.index + m[0].length;
let depth = 1;
while (depth > 0 && i < code.length) {
if (code[i] === "(") depth += 1;
else if (code[i] === ")") depth -= 1;
i += 1;
}
const arg = code.slice(m.index + m[0].length, i - 1);
if (/\bdeletedAt\b/.test(arg)) continue;
if (filtersThroughLiveObject(code, arg, m.index)) continue;
out.push({ model: m[1], arg: arg.replace(/\s+/g, " ").slice(0, 120) });
}
return out;
}
describe("analysis reads skip deleted rows", () => {
const models = tombstonedModels();
it("derives the soft-deleting models from the schema", () => {
expect(models.length).toBeGreaterThanOrEqual(20);
expect(models).toContain("moodEntry");
expect(models).toContain("measurement");
});
it("the matcher fires on an unfiltered read and passes a filtered one", () => {
const planted = `const rows = await prisma.moodEntry
.findMany({ where: { userId }, take: 90 });`;
expect(unfilteredReads(planted, models)).toHaveLength(1);
expect(
unfilteredReads(
"await prisma.moodEntry.findMany({ where: { userId, deletedAt: null } });",
models,
),
).toEqual([]);
const viaLocal = `const where = { userId, deletedAt: null };
await prisma.measurement.count({ where });`;
expect(unfilteredReads(viaLocal, models)).toEqual([]);
const viaLocalWithout = `const where = { userId };
await prisma.measurement.count({ where });`;
expect(unfilteredReads(viaLocalWithout, models)).toHaveLength(1);
});
it("no read in scope includes deleted rows outside the list", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
SCOPES.some((scope) => file.startsWith(scope)) &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
expect(files.length).toBeGreaterThan(400);
let reads = 0;
const readRe = new RegExp(`\\.(${models.join("|")})\\s*\\.\\s*find`, "g");
const offenders: string[] = [];
for (const file of files) {
const code = stripComments(readFileSync(join(SRC, file), "utf8"));
reads += [...code.matchAll(readRe)].length;
for (const hit of unfilteredReads(code, models)) {
const key = `${hit.model}@${file}`;
if (!(key in ALLOWED)) offenders.push(`${key}: ${hit.arg}`);
}
}
// The scope reads these models in well over a hundred places; a sweep
// that finds a handful read the wrong tree.
expect(reads).toBeGreaterThan(100);
expect(offenders).toEqual([]);
});
});
+3 -9
View File
@@ -142,13 +142,9 @@ export const GET = apiHandler(async () => {
createdAt: row.createdAt.toISOString(),
}));
// Exact: one row past the cap was asked for, so a list that is exactly
// full no longer reads as cut.
const truncated = cap.truncated;
annotate({
action: { name: "sharing.activity.list" },
meta: { entry_count: entries.length, truncated },
meta: { entry_count: entries.length, truncated: cap.truncated },
});
return apiListSuccess(
@@ -157,11 +153,9 @@ export const GET = apiHandler(async () => {
// The bound the view states out loud. Resolved here so the UI never has to
// hardcode a number the operator can change.
retentionDays,
// Whether the list is everything inside that window, or its most recent
// hundred rows. The view says which; silence would let the oldest line
// read as the beginning.
truncated,
},
// Whether the list is everything inside that window, or its most recent
// hundred rows, rides the envelope like every capped list's (`meta`).
cap,
);
});
+5 -1
View File
@@ -245,7 +245,11 @@ export const GET = apiHandler(async () => {
let hasSdnn = true;
if (!recoveryEnabled) {
const sdnnRow = await prisma.measurement.findFirst({
where: { userId: user.id, type: "HEART_RATE_VARIABILITY" },
where: {
userId: user.id,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
select: { id: true },
});
hasSdnn = sdnnRow !== null;
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] },
measuredAt: { gte: since },
},
@@ -28,6 +28,17 @@ vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
// The latest-night walk pages nights in SQL; its own behaviour is pinned by
// the SQL parity integration suite. Here it is stubbed to a fixed day so the
// route's "no date" arm can be read.
vi.mock("@/lib/measurements/list/sleep-nights", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/measurements/list/sleep-nights")
>();
return { ...actual, latestMainSleepNight: vi.fn(async () => null) };
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -49,6 +60,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { latestMainSleepNight } from "@/lib/measurements/list/sleep-nights";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -133,15 +145,13 @@ describe("GET /api/sleep/night", () => {
expect(Number.isInteger(body.data.main.awakeMinutes)).toBe(true);
});
it("defaults to the most recent scorable night when date omitted", async () => {
it("defaults to the latest main night when date omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-02T03:00:00.000Z", "CORE", 200),
stage("2026-06-04T03:00:00.000Z", "CORE", 300),
] as never);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
measuredAt: new Date("2026-06-04T03:00:00.000Z"),
} as never);
vi.mocked(latestMainSleepNight).mockResolvedValueOnce("2026-06-04");
const res = await GET(req(""));
const body = await res.json();
expect(body.data.night).toBe("2026-06-04");
+7 -3
View File
@@ -17,7 +17,9 @@
* (WHOOP) — the client renders the hypnogram but labels it an approximate
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
*
* `date` omitted → the most recent night that holds sleep, however old.
* `date` omitted → the most recent night with a main sleep, however old; a
* run of nap-only days since is stepped over (each stays reachable by its
* `date`). An account that has only ever napped gets its latest nap day.
*
* Only the stage rows within a day and a half of the night are read, as the
* list's drill-down reads them (`readNightSessions`), so any past night is
@@ -39,7 +41,7 @@ import {
type SleepSession,
} from "@/lib/analytics/sleep-night";
import {
latestSleepNight,
latestMainSleepNight,
readNightSessions,
} from "@/lib/measurements/list/sleep-nights";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -131,7 +133,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
]);
const targetDay =
date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined;
date ??
(await latestMainSleepNight(user.id, tz, priorityJson)) ??
undefined;
const { main, naps } = targetDay
? pickMainNightAndNaps(
(await readNightSessions(user.id, targetDay, tz, priorityJson))
@@ -56,8 +56,11 @@ function render(node: React.ReactNode) {
});
// Seed the ECG list the cross-link reads so its data-availability gate opens.
client.setQueryData(queryKeys.insightsEcgList(), {
recordings: [{ id: "a", classification: null }],
hasRecordings: true,
data: {
recordings: [{ id: "a", classification: null }],
hasRecordings: true,
},
cap: null,
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
+3 -6
View File
@@ -43,6 +43,7 @@ import {
type CatalogEntry,
} from "@/lib/insights/metric-catalog";
import type { ManagerGroup } from "@/lib/insights/sub-page-metric";
import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* `/insights/catalog` — "what can HealthLog track" (2026-07-17
@@ -252,11 +253,7 @@ export default function MetricCatalogPage() {
enabled: isAuthenticated && nutrientsModuleEnabled,
});
const ecgQuery = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
const ecgQuery = useEcgList();
const availability: InsightInputs | undefined = isAuthenticated
? {
@@ -319,7 +316,7 @@ export default function MetricCatalogPage() {
function isPresent(entry: CatalogEntry): boolean | null {
if (entry.kind === "info") return null;
if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true;
if (entry.id === "ecg") return ecgQuery.data?.data.hasRecordings === true;
if (!availability || !entry.metric) return false;
return hasMetricData(entry.metric, availability);
}
+1 -1
View File
@@ -52,7 +52,7 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.insightsEcgList(), data);
client.setQueryData(queryKeys.insightsEcgList(), { data, cap: null });
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
+3 -8
View File
@@ -2,17 +2,15 @@
import Link from "next/link";
import { Activity } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { EcgSection } from "@/components/insights/ecg-section";
import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.30 — `/insights/ecg` (UX/IA audit finding H1).
@@ -39,11 +37,8 @@ export default function InsightsEcgPage() {
// Reuse the SAME query cell `<EcgSection>` reads (`insightsEcgList`) so the
// page and the section share one cache entry. The page only needs the
// `hasRecordings` flag to choose between the empty state and the section.
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
const { data: page, isLoading, isError, error, refetch } = useEcgList();
const data = page?.data;
// A read failure must be visible, not a heading over an empty body that
// reads the same as "still loading" (§6). The section teaser below unmounts
+2 -39
View File
@@ -89,6 +89,7 @@ import {
DEFAULT_CHART_RANGE,
rangeWindowDays,
} from "./chart-range-tabs";
import { trendLineByTime } from "@/lib/stats/linear-fit";
// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and
// the "All" tab's ten-year fetch span live with the shared
@@ -585,44 +586,6 @@ export function resolveAnnotationPositions(
return out;
}
function buildTrendSeriesByTime(data: Array<{ date: Date; value: number }>): {
points: Array<{ date: Date; value: number }>;
slopePerDay: number;
} | null {
if (data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const startTime = sorted[0].date.getTime();
const points = sorted.map((p) => ({
x: (p.date.getTime() - startTime) / 86400000,
y: p.value,
}));
const n = points.length;
const sumX = points.reduce((s, p) => s + p.x, 0);
const sumY = points.reduce((s, p) => s + p.y, 0);
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
return {
points: sorted.map((p) => ({
date: p.date,
value:
Math.round(
(intercept + slope * ((p.date.getTime() - startTime) / 86400000)) *
100,
) / 100,
})),
slopePerDay: slope,
};
}
export function HealthChart({
types,
title,
@@ -1235,7 +1198,7 @@ export function HealthChart({
}));
if (typeData.length >= 2) {
const trend = buildTrendSeriesByTime(typeData);
const trend = trendLineByTime(typeData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
+2 -39
View File
@@ -54,6 +54,7 @@ import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
import { apiGet } from "@/lib/api/api-fetch";
import { trendLineByTime } from "@/lib/stats/linear-fit";
// --- Types ---
@@ -197,44 +198,6 @@ function movingAverageByPoints(
});
}
function buildTrendLine(data: Array<{ date: Date; value: number }>): {
points: Array<{ date: Date; value: number }>;
slopePerDay: number;
} | null {
if (data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const startTime = sorted[0].date.getTime();
const points = sorted.map((p) => ({
x: (p.date.getTime() - startTime) / 86400000,
y: p.value,
}));
const n = points.length;
const sumX = points.reduce((s, p) => s + p.x, 0);
const sumY = points.reduce((s, p) => s + p.y, 0);
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
return {
points: sorted.map((p) => ({
date: p.date,
value:
Math.round(
(intercept + slope * ((p.date.getTime() - startTime) / 86400000)) *
100,
) / 100,
})),
slopePerDay: slope,
};
}
// --- Aggregation helpers (exported for unit tests) ---
interface MoodEntryDay {
@@ -542,7 +505,7 @@ export function MoodChart({
}));
if (scoreData.length >= 2) {
const trend = buildTrendLine(scoreData);
const trend = trendLineByTime(scoreData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
+7 -2
View File
@@ -33,6 +33,7 @@ import {
useCycleInsights,
useCycleProfile,
} from "./use-cycle";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.15.0 — the cycle vertical's client orchestrator.
@@ -415,9 +416,13 @@ export function CycleView() {
<PredictionsPanel
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
history={history.data?.data}
/>
<CycleHistoryChart history={history.data} animate={play} />
<CycleHistoryChart
history={history.data?.data}
animate={play}
/>
<ListCapNotice cap={history.data?.cap} />
<BbtChart
days={calendar.data?.days ?? []}
today={today}
+3 -1
View File
@@ -17,6 +17,7 @@ import {
ApiError,
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
@@ -162,11 +163,12 @@ export function useCycleRingDial(enabled: boolean): {
};
}
/** The latest `limit` cycles, with the route's cap beside them. */
export function useCycleHistory(limit = 24) {
return useQuery({
queryKey: queryKeys.cycleHistory(limit),
queryFn: () =>
apiGet<CycleHistoryResponse>(`/api/cycle/cycles?limit=${limit}`),
apiGetList<CycleHistoryResponse>(`/api/cycle/cycles?limit=${limit}`),
staleTime: 60_000,
});
}
@@ -16,16 +16,22 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
const journal = vi.hoisted(() => ({ total: 230 }));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: async (url: string) => {
apiGet: vi.fn(),
apiGetList: async (url: string) => {
calls.push(url);
// The route's own paging: 50 unless asked otherwise, from offset 0.
// The route's own paging: 50 unless asked otherwise, from offset 0, and
// the cap it sends: a row past the page exists or it does not.
const params = new URL(url, "http://x").searchParams;
const offset = Number(params.get("offset") ?? 0);
const limit = Number(params.get("limit") ?? 50);
const total = 230;
const count = Math.max(0, Math.min(limit, total - offset));
return Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` }));
const count = Math.max(0, Math.min(limit, journal.total - offset));
return {
data: Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` })),
cap: { truncated: offset + count < journal.total, limit },
};
},
apiPost: vi.fn(),
apiPatch: vi.fn(),
@@ -47,4 +53,16 @@ describe("useIllnessEpisodes", () => {
true,
);
});
it("stops on a full last page the route says is the end", async () => {
calls.length = 0;
journal.total = 200;
const options = useIllnessEpisodes(true) as unknown as {
queryFn: () => Promise<{ id: string }[]>;
};
expect(await options.queryFn()).toHaveLength(200);
// Two full pages of 100, no third request for an empty one.
expect(calls).toHaveLength(2);
journal.total = 230;
});
});
+13 -4
View File
@@ -10,7 +10,13 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import {
illnessDependentKeys,
invalidateKeys,
@@ -37,7 +43,8 @@ const EPISODE_PAGE_SIZE = 100;
* most), and the journal, its settings and every picker read this as the
* whole record; a single unpaged call dropped the oldest episodes once there
* were more than one page of them, and a person then found an old condition
* only through search. So this reads page after page until a short one.
* only through search. So this reads page after page until the route says
* nothing is left.
*/
export function useIllnessEpisodes(includeResolved = true, enabled = true) {
return useQuery({
@@ -46,11 +53,13 @@ export function useIllnessEpisodes(includeResolved = true, enabled = true) {
queryFn: async () => {
const all: IllnessEpisodeDTO[] = [];
for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) {
const page = await apiGet<IllnessEpisodeDTO[]>(
const { data: page, cap } = await apiGetList<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`,
);
all.push(...page);
if (page.length < EPISODE_PAGE_SIZE) return all;
// The route says whether a row past this page exists, so a last page
// that is exactly full ends the walk without one more request.
if (!cap?.truncated) return all;
}
},
});
@@ -36,7 +36,9 @@ interface Item {
function render(
data: { recordings: Item[]; hasRecordings: boolean } | undefined,
) {
useQueryMock.mockReturnValue({ data });
useQueryMock.mockReturnValue({
data: data ? { data, cap: null } : undefined,
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgCrossLink />
@@ -54,8 +54,12 @@ function renderSection(
data:
{ recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined,
props: { limit?: number } = {},
cap: { truncated: boolean; limit: number } | null = null,
) {
useQueryMock.mockReturnValue({ data, isLoading: false });
useQueryMock.mockReturnValue({
data: data ? { data, cap } : undefined,
isLoading: false,
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgSection {...props} />
@@ -174,4 +178,18 @@ describe("<EcgSection>", () => {
expect(html).not.toContain('data-slot="ecg-disclaimer"');
expect(html).not.toContain("Single-lead ECG strips");
});
it("says when the full list stopped at the route's ceiling, never on the teaser", () => {
const data = { recordings: [IRREGULAR_REC], hasRecordings: true };
const cap = { truncated: true, limit: 200 };
expect(renderSection(data, {}, cap)).toContain(
'data-slot="list-cap-notice"',
);
expect(renderSection(data, { limit: 3 }, cap)).not.toContain(
'data-slot="list-cap-notice"',
);
expect(renderSection(data, {})).not.toContain(
'data-slot="list-cap-notice"',
);
});
});
@@ -42,7 +42,10 @@ interface RhythmEvent {
function renderWith(
data: { events: RhythmEvent[]; hasEvents: boolean } | undefined,
) {
useQueryMock.mockReturnValue({ data, isLoading: false });
useQueryMock.mockReturnValue({
data: data ? { data, cap: null } : undefined,
isLoading: false,
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RhythmEventsCard />
+2 -28
View File
@@ -2,14 +2,11 @@
import Link from "next/link";
import { ArrowRight, HeartPulse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
import { useEcgList } from "./use-ecg-list";
/**
* ECG cross-link, the pointer from the resting-HR / pulse context into
@@ -29,18 +26,6 @@ import { TileHeader } from "@/components/insights/tile-header";
* surfaces share one cache entry with an identical response shape.
*/
type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
interface EcgRecordingListItem {
id: string;
classification: EcgClassification;
}
interface EcgListResponse {
recordings: EcgRecordingListItem[];
hasRecordings: boolean;
}
/** The device verdict, surfaced verbatim and attributed to the device. */
const RESULT_LABEL_KEYS: Record<string, string> = {
IRREGULAR: "insights.ecg.result.irregular",
@@ -54,20 +39,9 @@ interface EcgCrossLinkProps {
}
export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: async () => {
try {
return await apiGet<EcgListResponse>("/api/insights/ecg");
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
enabled: enabled && isAuthenticated,
});
const data = useEcgList(enabled).data?.data;
// Data-availability gate — never paint an empty pointer.
if (!data || !data.hasRecordings || data.recordings.length === 0) return null;
+7 -21
View File
@@ -1,12 +1,8 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Activity, ChevronRight, HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
@@ -18,6 +14,8 @@ import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit";
import { DayLinkAt } from "@/components/day/day-link";
import { InsightSectionCard } from "./insight-section-card";
import { useEcgList } from "./use-ecg-list";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
// Re-exported so the tests (and any teaser call site) can keep importing the
// overview cap from the component they are exercising; the value itself lives
@@ -59,11 +57,6 @@ export interface EcgRecordingListItem {
hasWaveform: boolean;
}
interface EcgListResponse {
recordings: EcgRecordingListItem[];
hasRecordings: boolean;
}
interface EcgSectionProps {
enabled?: boolean;
className?: string;
@@ -89,21 +82,11 @@ export function EcgSection({
hideHeading = false,
limit,
}: EcgSectionProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: async () => {
try {
return await apiGet<EcgListResponse>("/api/insights/ecg");
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
enabled: enabled && isAuthenticated,
});
const { data: page, isLoading } = useEcgList(enabled);
const data = page?.data;
// Data-availability gate — never paint an empty card.
if (isLoading || !data || !data.hasRecordings) return null;
@@ -205,6 +188,9 @@ export function EcgSection({
);
})}
</ol>
{/* The teaser keeps its newest few on purpose; the full list says
when the server stopped short of the history. */}
{limit == null && <ListCapNotice cap={page?.cap} />}
</InsightSectionCard>
</section>
);
@@ -18,6 +18,7 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import { apiGet } from "@/lib/api/api-fetch";
import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
@@ -119,12 +120,8 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// ECG page, so the flag lands once per route (dedup-shared, no extra
// traffic). Undefined until it resolves → the pill stays off on first paint,
// exactly like the metric pills light up as their summaries land.
const ecgProbe = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
const hasEcgRecordings = ecgProbe.data?.hasRecordings === true;
const ecgProbe = useEcgList();
const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true;
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
+12 -3
View File
@@ -6,7 +6,8 @@ import type { LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { apiGetList } from "@/lib/api/api-fetch";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
@@ -91,11 +92,17 @@ export function RhythmEventsCard({
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
queryFn: async () => {
try {
return await apiGet<RhythmEventsResponse>(
return await apiGetList<RhythmEventsResponse>(
"/api/insights/rhythm-events",
);
} catch {
@@ -104,6 +111,7 @@ export function RhythmEventsCard({
},
enabled: enabled && isAuthenticated,
});
const data = page?.data;
// A failed read is said, never swallowed: this card carries health alerts,
// and silently rendering nothing on an error would hide them.
@@ -186,6 +194,7 @@ export function RhythmEventsCard({
);
})}
</ol>
<ListCapNotice cap={page?.cap} />
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
Plain React text children (no markdown library — XSS rule). */}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiGetList } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EcgRecordingListItem } from "./ecg-section";
export interface EcgListResponse {
recordings: EcgRecordingListItem[];
hasRecordings: boolean;
}
/**
* The ECG list, one query cell for every reader (the section, the page, the
* cross-link, the tab strip, the catalog). The cell holds `{ data, cap }`,
* so every reader shares one shape and the cap the route sends reaches the
* list that shows it.
*/
export function useEcgList(enabled = true) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
return useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: async () => {
try {
return await apiGetList<EcgListResponse>("/api/insights/ecg");
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
enabled: enabled && isAuthenticated,
});
}
@@ -1989,9 +1989,9 @@ function DayDrillDown({
}
/**
* v1.11.5 — sub-caption for a per-night sleep row: surfaces the nap count
* (naps are separate from the main night per the nap convention) and the
* night's mid-sleep awakenings when present.
* Sub-caption for a per-night sleep row: the row's value is the day's sleep,
* main night plus naps, so the caption says how many naps it includes, and
* the night's mid-sleep awakenings when present.
*/
function SleepNightCaption({ m }: { m: Measurement }) {
const { t } = useTranslations();
@@ -444,6 +444,7 @@ describe("<SideEffectsSection> — timeline rows", () => {
describe("<SideEffectsSection> — paging", () => {
function sideEffectsResponse(
items: Array<{ id: string; occurredAt: string }>,
truncated = false,
): Response {
return new Response(
JSON.stringify({
@@ -458,12 +459,32 @@ describe("<SideEffectsSection> — paging", () => {
meta: { total: items.length },
},
error: null,
// The route's cap on the envelope: a row past this page exists.
meta: { truncated, limit: 50 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
it("offers a next page after a full one and asks for the entries before it", async () => {
it("offers no next page after an exactly full one the route says is complete", async () => {
const base = Date.parse("2026-05-20T12:00:00.000Z");
const fullPage = Array.from({ length: 50 }, (_, i) => ({
id: `se-full-${i}`,
occurredAt: new Date(base - i * 60_000).toISOString(),
}));
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(sideEffectsResponse(fullPage, false)),
);
const client = makeClient();
render(<SideEffectsSection medicationId="med-full" />, client);
await executeSideEffectsQuery(client, "med-full");
await waitForQueryStatus(client, "med-full", "success");
const html = render(<SideEffectsSection medicationId="med-full" />, client);
expect(html).not.toContain(en.medications.sideEffects.loadMore);
});
it("offers a next page when the route says one exists, and asks for the entries before it", async () => {
const base = Date.parse("2026-05-20T12:00:00.000Z");
const firstPage = Array.from({ length: 50 }, (_, i) => ({
id: `se-${i}`,
@@ -472,7 +493,7 @@ describe("<SideEffectsSection> — paging", () => {
const oldest = firstPage[firstPage.length - 1];
const fetchMock = vi
.fn()
.mockResolvedValueOnce(sideEffectsResponse(firstPage))
.mockResolvedValueOnce(sideEffectsResponse(firstPage, true))
// The boundary entry comes back once more and is shown once.
.mockResolvedValueOnce(
sideEffectsResponse([
@@ -48,7 +48,7 @@ import {
severityLikertLabel,
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiFetchEnvelope, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -168,15 +168,19 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const from = pageParam
? new Date(pageParam.from)
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`;
const { data: payload } = await apiFetchEnvelope<{
const range = `from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`;
const { data: payload, cap } = await apiGetList<{
items: SideEffectRow[];
meta: { total: number };
}>(url);
return { ...payload, from: from.toISOString() };
}>(
`/api/medications/${medicationId}/side-effects?${range}&limit=${SIDE_EFFECTS_PAGE_SIZE}`,
);
return { ...payload, cap, from: from.toISOString() };
},
getNextPageParam: (lastPage): SideEffectPageParam | undefined => {
if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined;
// The route says whether a row past the page exists; a page that is
// exactly full is not a cut one.
if (!lastPage.cap?.truncated) return undefined;
const oldest = lastPage.items[lastPage.items.length - 1];
return {
from: lastPage.from,
@@ -43,7 +43,8 @@ function withProviders(node: React.ReactNode): string {
// settled empty result. The failed and in-flight states are covered in
// `failed-read-never-empty.test.tsx`.
queryClient.setQueryData(queryKeys.mentalHealthAssessments(), {
assessments: [],
data: { assessments: [] },
cap: null,
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
@@ -31,7 +31,7 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGetList, apiPost } from "@/lib/api/api-fetch";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
import { PageHeader } from "@/components/ui/page-header";
@@ -49,6 +49,7 @@ import type {
InstrumentId,
Phase,
} from "./types";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
export function MentalWellbeing() {
const { t, locale } = useTranslations();
@@ -72,7 +73,7 @@ export function MentalWellbeing() {
// assessments was told the assessments do not exist. Error is now rendered as
// error, loading as loading, and only a genuinely empty result as empty.
const {
data: history,
data: historyPage,
isPending: historyPending,
isError: historyError,
error: historyFailure,
@@ -80,10 +81,11 @@ export function MentalWellbeing() {
} = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
queryFn: () =>
apiGet<{ assessments: AssessmentRow[] }>(
apiGetList<{ assessments: AssessmentRow[] }>(
"/api/mental-health/assessments",
),
});
const history = historyPage?.data;
const mutation = useMutation({
mutationFn: (body: unknown) =>
@@ -230,6 +232,7 @@ export function MentalWellbeing() {
onStart={() => begin(detailInstrument)}
/>
)}
<ListCapNotice cap={historyPage?.cap} className="mt-3" />
</ResponsiveSheet>
</>
)}
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: mocks.rows,
data: { data: mocks.rows, cap: null },
isLoading: false,
isError: false,
refetch: vi.fn(),
@@ -18,6 +18,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("@/lib/api/api-fetch", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiGetList: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/components/ui/responsive-sheet", () => ({
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: mocks.rows,
data: { data: mocks.rows, cap: null },
isLoading: false,
isError: false,
refetch: vi.fn(),
@@ -18,6 +18,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("@/lib/api/api-fetch", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiGetList: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/components/ui/responsive-sheet", () => ({
+15 -5
View File
@@ -14,12 +14,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiGetList } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { AllergyForm } from "./allergy-form";
import type { AllergyDTO } from "@/lib/records/dto";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.25 — the structured allergy/intolerance manager. Renders in
@@ -43,9 +44,15 @@ export function AllergyManager({
const [editing, setEditing] = useState<AllergyDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.allergyList(true),
queryFn: () => apiGet<AllergyDTO[]>("/api/allergies"),
queryFn: () => apiGetList<AllergyDTO[]>("/api/allergies"),
});
const deleteMutation = useMutation({
@@ -74,7 +81,7 @@ export function AllergyManager({
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
}
const rows = data ?? [];
const rows = page?.data ?? [];
function renderRow(row: AllergyDTO) {
const detail = [
@@ -168,7 +175,10 @@ export function AllergyManager({
}
/>
) : (
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<>
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<ListCapNotice cap={page?.cap} />
</>
)}
{/* The add action closes the list (design standards §12); an empty
@@ -13,12 +13,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiGetList } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { FamilyHistoryForm } from "./family-history-form";
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.25 — the structured family-history manager. Renders in the
@@ -33,9 +34,15 @@ export function FamilyHistoryManager() {
const [editing, setEditing] = useState<FamilyHistoryEntryDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.familyHistoryList(),
queryFn: () => apiGet<FamilyHistoryEntryDTO[]>("/api/family-history"),
queryFn: () => apiGetList<FamilyHistoryEntryDTO[]>("/api/family-history"),
});
const deleteMutation = useMutation({
@@ -64,7 +71,7 @@ export function FamilyHistoryManager() {
queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() });
}
const rows = data ?? [];
const rows = page?.data ?? [];
function renderRow(row: FamilyHistoryEntryDTO) {
const detail = [
@@ -130,7 +137,10 @@ export function FamilyHistoryManager() {
}
/>
) : (
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<>
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<ListCapNotice cap={page?.cap} />
</>
)}
{/* The add action closes the list as the card's action row (design
@@ -32,7 +32,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
// Seed the embedded session + trusted-device + activity reads so the
// assembled cards paint without firing network.
client.setQueryData(queryKeys.sessions(), { sessions: [] });
client.setQueryData(queryKeys.securityActivity(), { events: [] });
client.setQueryData(queryKeys.securityActivity(), {
data: { events: [] },
cap: null,
});
// Seed a trusted device so the card paints (it hides itself when empty).
client.setQueryData(queryKeys.trustedDevices(), {
devices: [
@@ -80,7 +83,10 @@ describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
},
});
client.setQueryData(queryKeys.sessions(), { sessions: [] });
client.setQueryData(queryKeys.securityActivity(), { events: [] });
client.setQueryData(queryKeys.securityActivity(), {
data: { events: [] },
cap: null,
});
client.setQueryData(queryKeys.trustedDevices(), { devices: [] });
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
@@ -32,7 +32,9 @@ function grantList(partial: Partial<GrantList> = {}): GrantList {
return { given: [], received: [], retentionDays: 365, ...partial };
}
const activityRef: { value: RecordActivity | undefined } = { value: undefined };
const activityRef: {
value: (RecordActivity & { cap: ListCap | null }) | undefined;
} = { value: undefined };
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
const actual =
@@ -64,6 +66,7 @@ vi.mock("@/hooks/use-account-switch", () => ({
import { GrantsGivenCard } from "../grants-given-card";
import { GrantsReceivedCard } from "../grants-received-card";
import { RecordActivityCard } from "../record-activity-card";
import type { ListCap } from "@/lib/api/list-cap-shape";
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
@@ -205,7 +208,7 @@ describe("when my record was opened", () => {
// see would imply a completeness it does not have: somebody checking last
// spring would read an empty list as "no" when the honest answer is
// "further back than this reaches".
activityRef.value = { entries: [], retentionDays: 90, truncated: false };
activityRef.value = { entries: [], retentionDays: 90, cap: null };
const html = render(<RecordActivityCard />);
expect(html).toContain('data-slot="record-activity-retention"');
expect(html).toContain("last 90 days");
@@ -215,22 +218,26 @@ describe("when my record was opened", () => {
// The second completeness claim. The window bounds how far back; the row
// cap bounds how many, and a list that stopped at its ceiling in silence
// let the oldest visible line read as the beginning.
activityRef.value = { entries: [], retentionDays: 365, truncated: true };
activityRef.value = {
entries: [],
retentionDays: 365,
cap: { truncated: true, limit: 100 },
};
const capped = render(<RecordActivityCard />);
expect(capped).toContain('data-slot="record-activity-truncated"');
expect(capped).toContain("most recent activity");
expect(capped).toContain('data-slot="list-cap-notice"');
expect(capped).toContain("Showing the latest 100");
// And it is not an unconditional hedge: a caveat that is always on is one
// people learn to skip, which would cost the sentence its meaning on the
// one screen where it is true.
activityRef.value = { entries: [], retentionDays: 365, truncated: false };
activityRef.value = { entries: [], retentionDays: 365, cap: null };
const whole = render(<RecordActivityCard />);
expect(whole).not.toContain('data-slot="record-activity-truncated"');
expect(whole).not.toContain('data-slot="list-cap-notice"');
expect(whole).toContain('data-slot="record-activity-retention"');
});
it("prints the operator's window, never a compiled-in 365", () => {
activityRef.value = { entries: [], retentionDays: 30, truncated: false };
activityRef.value = { entries: [], retentionDays: 30, cap: null };
const html = render(<RecordActivityCard />);
expect(html).toContain("last 30 days");
expect(html).not.toContain("365");
@@ -247,7 +254,7 @@ describe("when my record was opened", () => {
it("counts the openings rather than listing them one by one", () => {
activityRef.value = {
retentionDays: 365,
truncated: false,
cap: null,
entries: [
{
id: "a1",
@@ -270,7 +277,7 @@ describe("when my record was opened", () => {
// it themselves.
activityRef.value = {
retentionDays: 365,
truncated: false,
cap: null,
entries: [
{
id: "a2",
@@ -285,3 +292,19 @@ describe("when my record was opened", () => {
expect(html).toContain("A deleted account");
});
});
describe("grant lists at their ceiling", () => {
it("say so under the list, and only when the server cut it", () => {
grantsRef.value = {
...grantList({ given: [BASE] }),
cap: { truncated: true, limit: 100 },
} as GrantList;
expect(render(<GrantsGivenCard />)).toContain(
'data-slot="list-cap-notice"',
);
grantsRef.value = grantList({ given: [BASE] });
expect(render(<GrantsGivenCard />)).not.toContain(
'data-slot="list-cap-notice"',
);
});
});
@@ -19,6 +19,7 @@ import {
useRevokeGrant,
type GrantRow,
} from "@/lib/queries/use-account-grants";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.36.0 — who can open this record.
@@ -87,6 +88,7 @@ export function GrantsGivenCard() {
))}
</ul>
)}
<ListCapNotice cap={data?.cap} />
</div>
</SettingsCard>
);
@@ -23,6 +23,7 @@ import {
useRenounceGrant,
type GrantRow,
} from "@/lib/queries/use-account-grants";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.36.0 — the records this account has been offered, and the ones it holds.
@@ -110,6 +111,7 @@ export function GrantsReceivedCard() {
))}
</ul>
)}
<ListCapNotice cap={data?.cap} />
</div>
</SettingsCard>
);
@@ -13,6 +13,7 @@ import {
useRecordActivity,
type RecordActivityEntry,
} from "@/lib/queries/use-account-grants";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.36.0 — when this record was opened, and by whom.
@@ -63,22 +64,12 @@ export function RecordActivityCard() {
{t("recordSharing.activity.retention", {
days: data.retentionDays,
})}
{/* The second completeness claim. The window says how far back
this can see; the cap says how many rows came back, and a list
that stopped at its ceiling without saying so lets the oldest
line read as the beginning. Rendered only when the ceiling was
actually hit — an unconditional hedge would teach people to
ignore it. */}
{data.truncated && (
<>
{" "}
<span data-slot="record-activity-truncated">
{t("recordSharing.activity.truncated")}
</span>
</>
)}
</p>
)}
{/* The second completeness claim: the window says how far back this
can see, the cap how many rows came back. Rendered only when the
ceiling was actually hit. */}
<ListCapNotice cap={data?.cap} />
{isLoading && (
<Loader2
@@ -18,8 +18,9 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { apiGetList } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
interface ActivityRow {
action: string;
@@ -68,14 +69,20 @@ export function SecurityActivityCard({
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
apiGetList<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
enabled: isAuthenticated,
});
const events = data?.events ?? [];
const events = page?.data.events ?? [];
return (
<SettingsCard data-slot="settings-security-activity-card">
@@ -152,6 +159,7 @@ export function SecurityActivityCard({
))}
</ul>
)}
<ListCapNotice cap={page?.cap} />
</div>
</SettingsCard>
);
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { readListCap } from "@/lib/api/list-cap-shape";
import { ListCapNotice } from "../list-cap-notice";
const render = (node: React.ReactNode) =>
renderToStaticMarkup(<I18nProvider initialLocale="en">{node}</I18nProvider>);
describe("ListCapNotice", () => {
it("says the list stopped at its ceiling, with the number", () => {
const html = render(<ListCapNotice cap={{ truncated: true, limit: 50 }} />);
expect(html).toContain('data-slot="list-cap-notice"');
expect(html).toContain("Showing the latest 50");
});
it("renders nothing for a complete list or no cap", () => {
expect(
render(<ListCapNotice cap={{ truncated: false, limit: 50 }} />),
).toBe("");
expect(render(<ListCapNotice cap={null} />)).toBe("");
});
});
describe("readListCap", () => {
it("reads the envelope's cap and nothing else", () => {
expect(
readListCap({ truncated: true, limit: 100, requestId: "r" }),
).toEqual({ truncated: true, limit: 100 });
expect(readListCap({ aggregate: {} })).toBeNull();
expect(readListCap({ truncated: "yes", limit: 1 })).toBeNull();
expect(readListCap(undefined)).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
"use client";
import type { ListCap } from "@/lib/api/list-cap-shape";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* The line under a list the server cut at its ceiling. Renders nothing for a
* complete list, so a surface passes the cap it read (`apiGetList`) and
* never decides on its own whether to show it. Meta tier: one muted sentence
* at the `text-xs` floor.
*/
export function ListCapNotice({
cap,
className,
}: {
cap: ListCap | null | undefined;
className?: string;
}) {
const { t } = useTranslations();
if (!cap?.truncated) return null;
return (
<p
data-slot="list-cap-notice"
className={cn("text-muted-foreground text-xs", className)}
>
{t("common.listCapped", { count: String(cap.limit) })}
</p>
);
}
+28 -3
View File
@@ -1282,6 +1282,24 @@ export function separateNightsAndNaps(
return days.sort((a, b) => a.night.localeCompare(b.night));
}
/**
* A wake day's sleep total, the one definition every reader states: the main
* night plus every nap of the day. It equals `SleepNight.asleepMinutes` for
* the same rows (both sum each session's canonical-writer asleep minutes),
* which is what the series, the sleep score and the composition read; the
* main night alone stays available as `main.asleepMinutes` /
* `SleepNight.mainSession`. A day of naps alone totals its naps, not 0.
*/
export function daySleepMinutes(day: {
main: SleepSession | null;
naps: readonly SleepSession[];
}): number {
return (
(day.main?.asleepMinutes ?? 0) +
day.naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0)
);
}
export interface SleepNightSummary {
/**
* A `DataSummary` whose every value is a per-night TIME-ASLEEP total
@@ -1306,9 +1324,16 @@ export function summarizeSleepNights(
tz: string,
priorityJson: unknown = null,
): SleepNightSummary {
const nights = reconstructSleepNights(rows, tz, priorityJson).filter(
(n) => n.asleepMinutes > 0,
);
return summarizeNights(reconstructSleepNights(rows, tz, priorityJson));
}
/**
* `summarizeSleepNights` over nights already reconstructed, for a reader
* that gets them from the chunked Postgres walk (`readSleepNights`) instead
* of holding the stage rows itself.
*/
export function summarizeNights(all: readonly SleepNight[]): SleepNightSummary {
const nights = all.filter((n) => n.asleepMinutes > 0);
const dataPoints = nights.map((n) => ({
date: n.measuredAt,
value: n.asleepMinutes,
+17 -27
View File
@@ -103,17 +103,20 @@ import {
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import {
summarizeSleepNights,
summarizeNights,
type SleepSourceDiscrepancy,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { userDayKey } from "@/lib/tz/format";
import { localDayWindow } from "@/lib/tz/local-day";
import { canonicalRowsOfDay } from "@/lib/mood/linked-context";
const DAY_MS = 24 * 60 * 60 * 1000;
/** No account holds this many nights; the walk is uncapped in practice. */
const ALL_NIGHTS = 100_000;
/**
* v1.18.10 — upper bound on the cold-fallback `allTime` aggregate's
* scan window. Sits far beyond any real account's history (the earliest prod
@@ -459,9 +462,10 @@ async function withLatestDayValues(
* summary with one computed over per-night asleep totals (one DataPoint
* per night). The unit stays MINUTES (canonical), so existing consumers
* that already treat the summary as minutes keep working — they now
* average nights instead of stages, which is strictly more correct. The
* read is bounded to a year (~5 rows/night) and is skipped entirely when
* the user has logged no sleep.
* average nights instead of stages, which is strictly more correct. It
* covers the whole history, like every other type here (a year's cut left
* `avg30LastYear` and the all-time count and range short), and is skipped
* entirely when the user has logged no sleep.
*/
async function withSleepNightTotals(
userId: string,
@@ -471,32 +475,18 @@ async function withSleepNightTotals(
// No sleep data at all → nothing to recompute (the empty summary stays).
if (!existing || existing.count === 0) return slice;
const since = new Date(Date.now() - 365 * DAY_MS);
const rows = (await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
// `deviceType` feeds the writer-level collapse: two HealthKit apps
// behind one source (watch stages vs phone in-bed) must not blend.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})) as SleepStageRow[];
if (rows.length === 0) return slice;
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const { summary, latestNight } = summarizeSleepNights(rows, tz, priorityJson);
// The whole history, as every other type in the slice: the walk names the
// nights in Postgres and reconstructs them ninety at a time, so the heap
// holds a chunk's rows however long the history is.
const nights = await readSleepNights(userId, tz, priorityJson, {
maxNights: ALL_NIGHTS,
});
if (nights.length === 0) return slice;
const { summary, latestNight } = summarizeNights(nights);
// Preserve the slope tuples the night summary doesn't compute (the
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /
+8 -19
View File
@@ -4,6 +4,7 @@
*/
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
import { linearFit } from "@/lib/stats/linear-fit";
export interface DataPoint {
date: Date;
@@ -48,26 +49,14 @@ export function trendSlope(
y: p.value,
}));
const n = points.length;
const sumX = points.reduce((s, p) => s + p.x, 0);
const sumY = points.reduce((s, p) => s + p.y, 0);
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
// R² calculation
const meanY = sumY / n;
const ssTotal = points.reduce((s, p) => s + (p.y - meanY) ** 2, 0);
const ssResidual = points.reduce(
(s, p) => s + (p.y - (intercept + slope * p.x)) ** 2,
0,
const fit = linearFit(
points.map((p) => p.x),
points.map((p) => p.y),
);
const rSquared = ssTotal === 0 ? 0 : 1 - ssResidual / ssTotal;
if (!fit) return null;
const { slope } = fit;
// No variance in y: a flat line explains nothing, as it always read.
const rSquared = fit.r2 ?? 0;
const threshold = 0.01; // units per day threshold for "stable"
const direction: "up" | "down" | "stable" =
+30
View File
@@ -56,6 +56,7 @@ import {
validateResponseContext,
withRecordFenceHeaders,
} from "./record-fence";
import { readListCap, type ListCap } from "@/lib/api/list-cap-shape";
/** HTTP-level failure from an `/api/...` route — non-OK status. */
export class ApiError extends Error {
@@ -214,6 +215,35 @@ function withJsonBody(
return merged;
}
/**
* GET a capped list — resolves with `data` and the envelope's list cap
* (`meta.truncated` / `meta.limit`), null when the route sent none. The one
* client reader of the cap; pair it with `ListCapNotice`.
*/
export async function apiGetList<T = unknown>(
path: string,
init?: RequestInit,
): Promise<{ data: T; cap: ListCap | null }> {
const { data, meta } = await apiFetchEnvelope<T>(path, {
...init,
method: "GET",
});
return { data, cap: readListCap(meta) };
}
/** POST variant of {@link apiGetList}, for a list read with a body. */
export async function apiPostList<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<{ data: T; cap: ListCap | null }> {
const { data, meta } = await apiFetchEnvelope<T>(
path,
withDefaultIdempotencyKey(withJsonBody("POST", body, init)),
);
return { data, cap: readListCap(meta) };
}
/** GET — resolves with the unwrapped `data` payload. */
export function apiGet<T = unknown>(
path: string,
+22
View File
@@ -0,0 +1,22 @@
/**
* The wire shape of a list cap, safe for the client bundle.
*
* A capped list answers with `meta: { truncated, limit }` on the envelope
* (`apiListSuccess` in `./list-cap.ts`). That is the one place the signal
* rides; `readListCap` is the one place a client reads it.
*/
export interface ListCap {
/** A row past `limit` exists and was not returned. */
truncated: boolean;
/** The most rows this response could carry. */
limit: number;
}
/** The cap an envelope's `meta` carries, or null when it carries none. */
export function readListCap(meta: unknown): ListCap | null {
if (!meta || typeof meta !== "object") return null;
const { truncated, limit } = meta as Record<string, unknown>;
if (typeof truncated !== "boolean") return null;
if (typeof limit !== "number" || !Number.isFinite(limit)) return null;
return { truncated, limit };
}
+7 -7
View File
@@ -17,16 +17,16 @@
* `data` keeps exactly the shape the route always had and a client that never
* reads `meta` decodes it unchanged. `truncated` is exact: true only when a
* row past the limit exists. The OpenAPI side is `listEnvelope` in
* `src/lib/openapi/routes/shared.ts`.
* `src/lib/openapi/routes/shared.ts`. A client reads it through `apiGetList`
* / `apiPostList` (`src/lib/api/api-fetch.ts`) and shows it with
* `ListCapNotice`; `src/__tests__/list-cap-reader-guard.test.ts` binds every
* capped route to such a reader.
*/
import { apiSuccessWithMeta } from "@/lib/api-response";
export interface ListCap {
/** A row past `limit` exists and was not returned. */
truncated: boolean;
/** The most rows this response could carry. */
limit: number;
}
import type { ListCap } from "./list-cap-shape";
export type { ListCap } from "./list-cap-shape";
/** The `take` that lets {@link capRows} tell a full list from a cut one. */
export function overfetch(limit: number): number {
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
const addWarning = vi.fn();
vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addWarning }) }));
import { readSealedOrLegacy, warnAndDrop } from "../sealed-or-legacy";
const sealed = new Uint8Array([1, 2, 3]);
const open = (bytes: Uint8Array) => `opened:${bytes.length}`;
const broken = () => {
throw new Error("bad tag");
};
describe("readSealedOrLegacy", () => {
it("reads the ciphertext first, even with a readable value beside it", () => {
expect(
readSealedOrLegacy({
sealed,
legacy: "readable",
open,
onUndecryptable: "throw",
}),
).toBe("opened:3");
});
it("reads the readable column only when there is no ciphertext", () => {
for (const none of [null, undefined, new Uint8Array(0)]) {
expect(
readSealedOrLegacy({
sealed: none,
legacy: "readable",
open,
onUndecryptable: "throw",
}),
).toBe("readable");
}
expect(
readSealedOrLegacy({
sealed: null,
legacy: null,
open,
onUndecryptable: "throw",
}),
).toBeNull();
});
it("throws on a ciphertext that does not open when told to", () => {
expect(() =>
readSealedOrLegacy({
sealed,
legacy: "readable",
open: broken,
onUndecryptable: "throw",
}),
).toThrow("bad tag");
});
it("drops with a warning, never falling back to the readable value", () => {
expect(
readSealedOrLegacy({
sealed,
legacy: "readable",
open: broken,
onUndecryptable: warnAndDrop("thing did not open"),
}),
).toBeNull();
expect(addWarning).toHaveBeenCalledWith("thing did not open: bad tag");
});
});
+7 -4
View File
@@ -24,6 +24,7 @@
* when there is NO ciphertext (a legacy row not yet covered by the backfill).
*/
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { readSealedOrLegacy } from "@/lib/crypto/sealed-or-legacy";
/**
* Encrypt a free-text note for the `Bytes` ciphertext column. Returns `null`
@@ -51,10 +52,12 @@ export function readNote(
ciphertext: Uint8Array | null | undefined,
plaintextFallback: string | null | undefined,
): string | null {
if (ciphertext && ciphertext.byteLength > 0) {
return decryptFromBytes(ciphertext);
}
return plaintextFallback ?? null;
return readSealedOrLegacy({
sealed: ciphertext,
legacy: plaintextFallback,
open: decryptFromBytes,
onUndecryptable: "throw",
});
}
/**
+53
View File
@@ -0,0 +1,53 @@
/**
* The one read rule for a value stored sealed beside a legacy readable column.
*
* Several columns moved from plaintext to AES-256-GCM and keep the readable
* column until a backfill reaches every row: notes, workout tracks, the cached
* insights payload. Each reader once picked its own order and its own answer
* to a ciphertext that does not open, and they disagreed: two read the
* ciphertext first, one read the readable column first, and of the two that
* opened the ciphertext first one threw and one returned nothing.
*
* The order is fixed here and is not a parameter. A present ciphertext is
* always the answer: the readable column is read only when the row has no
* ciphertext, never as a stand-in for one that failed to open, because that
* would serve whatever an older writer left in the clear in place of the value
* the key was meant to protect. What a failure to open means is the caller's
* decision and must be stated: `"throw"` for a record whose loss must surface,
* or a handler for a value whose absence the surface can carry, typically
* {@link warnAndDrop}.
*/
import { getEvent } from "@/lib/logging/context";
export type OnUndecryptable<T> = "throw" | ((error: unknown) => T | null);
export function readSealedOrLegacy<T>(args: {
sealed: Uint8Array | null | undefined;
legacy: T | null | undefined;
open: (sealed: Uint8Array) => T;
onUndecryptable: OnUndecryptable<T>;
}): T | null {
const { sealed, legacy, open, onUndecryptable } = args;
if (sealed && sealed.byteLength > 0) {
if (onUndecryptable === "throw") return open(sealed);
try {
return open(sealed);
} catch (error) {
return onUndecryptable(error);
}
}
return legacy ?? null;
}
/**
* A handler that records the failure as a warning on the request's event and
* reads the value as absent.
*/
export function warnAndDrop(label: string): (error: unknown) => null {
return (error) => {
getEvent()?.addWarning(
`${label}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
};
}
+9 -12
View File
@@ -11,6 +11,7 @@
*/
import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema";
import { prisma } from "@/lib/db";
import { readLatestEver } from "@/lib/dashboard/summary-reads";
import type { Locale } from "@/lib/i18n/config";
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
import { userDayKey } from "@/lib/tz/format";
@@ -41,21 +42,17 @@ export async function readSignalLastSeen(
userId: string,
): Promise<(type: string) => string | null> {
try {
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: {
userId,
deletedAt: null,
type: {
in: Object.values(SIGNAL_READING_TYPE) as MeasurementType[],
},
},
_max: { measuredAt: true },
});
// One index probe per type for its latest live reading
// (`readLatestEver`), rather than a grouped max over every row.
const rows = await readLatestEver(
prisma,
userId,
Object.values(SIGNAL_READING_TYPE) as MeasurementType[],
);
const byType = new Map(
rows.map((row) => [
String(row.type),
row._max.measuredAt?.toISOString() ?? null,
new Date(row.measured_at).toISOString(),
]),
);
return (type) => byType.get(type) ?? null;
+19 -14
View File
@@ -17,6 +17,7 @@
import { formatInUserTz } from "./tz/format";
import {
reconstructSleepSessions,
daySleepMinutes,
pickMainNightAndNaps,
type SleepStageRow,
} from "./analytics/sleep-night";
@@ -142,9 +143,9 @@ interface ExportMeasurement {
*
* v1.11.5 — SLEEP_DURATION is stored one row per STAGE per night. A flat
* export emitted a wall of per-stage minutes with no night attribution.
* By default sleep rows are now COLLAPSED to one record per night (the
* main session's TIME ASLEEP per the nap convention) carrying the stage
* breakdown in `notes` so the single uniform CSV reads as one night, not
* By default sleep rows are now COLLAPSED to one record per wake day (the
* day's TIME ASLEEP, main night plus naps, `daySleepMinutes`) carrying the
* main night's stage breakdown and the naps in `notes` so the single uniform CSV reads as one night, not
* many stages. Pass `granularity: "raw"` to keep the per-stage rows for
* power users. Storage is untouched either way.
*/
@@ -224,11 +225,14 @@ export function formatMeasurementsForExport(
const nightRecords: Array<{ at: Date; rec: ExportableRecord }> = [];
for (const daySessions of byDay.values()) {
const { main, naps } = pickMainNightAndNaps(daySessions);
if (!main) continue;
const stageParts = (Object.entries(main.stages) as Array<[string, number]>)
.filter(([, mins]) => mins > 0)
.map(([stage, mins]) => `${stage}=${Math.round(mins)}m`)
.join(" ");
const lead = main ?? naps.at(-1);
if (!lead) continue;
const stageParts = main
? (Object.entries(main.stages) as Array<[string, number]>)
.filter(([, mins]) => mins > 0)
.map(([stage, mins]) => `${stage}=${Math.round(mins)}m`)
.join(" ")
: "";
const napNote =
naps.length > 0
? ` naps=${naps.length}(${Math.round(
@@ -236,16 +240,17 @@ export function formatMeasurementsForExport(
)}m)`
: "";
const awakeningNote =
main.awakenings > 0 ? ` awakenings=${main.awakenings}` : "";
main && main.awakenings > 0 ? ` awakenings=${main.awakenings}` : "";
nightRecords.push({
at: main.end,
at: lead.end,
rec: {
type: "SLEEP_DURATION",
// Headline value = the main night's TIME ASLEEP in minutes.
value: Math.round(main.asleepMinutes),
// Headline value = the day's TIME ASLEEP in minutes, the main night
// plus its naps (`daySleepMinutes`), as the list and the series read.
value: Math.round(daySleepMinutes({ main, naps })),
unit: "minutes",
measuredAt: formatTimestamp(main.end, userTz),
source: main.source ?? "",
measuredAt: formatTimestamp(lead.end, userTz),
source: lead.source ?? "",
notes: `${stageParts}${napNote}${awakeningNote}`.trim(),
glucoseContext: "",
},
+20 -5
View File
@@ -44,6 +44,7 @@
* Privacy: the result carries counts only, per type and per app. No value,
* no timestamp and nothing a person typed (notes, titles) leaves this module.
*/
import { importValue } from "@/lib/integrations/import-value";
import type {
FlowLevel,
GlucoseContext,
@@ -547,7 +548,7 @@ class ImportRun {
this.stat("PULSE").read += 1;
await sink.push({
type: "PULSE",
value: Math.round(mean * 10) / 10,
value: importValue(mean, 1),
valueMin: hcNumber(best.lo),
valueMax: hcNumber(best.hi),
// The middle of the local hour, in the record's own zone.
@@ -889,7 +890,10 @@ class ImportRun {
const days: Array<{ dayKey: string; value: number }> = [];
for (const day of this.dailyGroups(sql, HC_CATEGORY.ACTIVITY)) {
this.stat(spec.type).read += 1;
days.push({ dayKey: day.dayKey, value: day.total * spec.factor });
days.push({
dayKey: day.dayKey,
value: importValue(day.total * spec.factor),
});
if (days.length >= WRITE_BATCH) {
await this.writeDayTotals(spec.type, days.splice(0));
}
@@ -1136,7 +1140,7 @@ class ImportRun {
): Promise<void> {
if (entries.length === 0) return;
const valid = entries.filter((e) => {
const amount = Math.round(e.amount * 1000) / 1000;
const amount = importValue(e.amount, 3);
if (amount > NUTRIENT_CATALOG[e.code].plausibleDailyMax) {
this.nutrients.outOfRange += 1;
this.skip("out_of_range", 1, `nutrient:${e.code}`);
@@ -1160,7 +1164,7 @@ class ImportRun {
);
const fresh: Prisma.NutrientIntakeDayCreateManyInput[] = [];
for (const e of valid) {
const amount = Math.round(e.amount * 1000) / 1000;
const amount = importValue(e.amount, 3);
const prior = existing.get(`${e.day}|${e.code}`);
if (prior === undefined) {
fresh.push({
@@ -1597,8 +1601,19 @@ class MeasurementSink {
private readonly hourlyPulse = false,
) {}
/**
* Every measurement value this import writes passes here (or through
* `writeDayTotals`), so this is where it takes the one import rounding:
* a mapper's unit factor (grams to kilograms, millis to minutes) leaves
* binary noise that would otherwise be stored and compared as is.
*/
async push(row: PendingRow): Promise<void> {
this.rows.push(row);
this.rows.push({
...row,
value: importValue(row.value),
valueMin: row.valueMin == null ? row.valueMin : importValue(row.valueMin),
valueMax: row.valueMax == null ? row.valueMax : importValue(row.valueMax),
});
if (this.rows.length >= WRITE_BATCH) await this.flush();
}
@@ -41,13 +41,25 @@ describe("cached insights payload at rest", () => {
).toBe(PAYLOAD);
});
it("prefers a readable value over a sealed one, since only an older writer can have put it there", () => {
// This used to prefer the readable column, the one sealed column read
// that way. Every sealed column now reads the ciphertext first.
it("reads a sealed value before a readable one beside it", () => {
expect(
openInsightsCachedText({
insightsCachedText: "newer, from the previous release",
insightsCachedTextEncrypted: sealInsightsCachedText("older"),
insightsCachedText: "readable",
insightsCachedTextEncrypted: sealInsightsCachedText("sealed"),
}),
).toBe("newer, from the previous release");
).toBe("sealed");
});
it("never serves the readable value in place of a sealed one that does not open", () => {
const foreign = encryptBytes(Buffer.from(PAYLOAD, "utf8"), "other/v1");
expect(
openInsightsCachedText({
insightsCachedText: "readable",
insightsCachedTextEncrypted: new Uint8Array(foreign),
}),
).toBeNull();
});
it("does not open a value sealed for another purpose", () => {
@@ -703,3 +703,22 @@ describe("extractFeatures — blood-pressure spread", () => {
expect(features.bloodPressure?.sdSys30).toBe(8.2);
});
});
describe("extractFeatures — mood fallback window", () => {
it("reads the raw fallback over the rollup's five years, not one", async () => {
// Empty rollup tier: the raw fallback answers.
prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
await extractFeatures("user-mood-window", false, {
only: new Set(["mood"]),
});
const call = prismaMock.moodEntry.findMany.mock.calls.find(
([args]) =>
(args as { where?: { moodLoggedAt?: unknown } })?.where?.moodLoggedAt,
);
expect(call).toBeDefined();
const since = (call![0] as { where: { moodLoggedAt: { gte: Date } } }).where
.moodLoggedAt.gte;
const years = (Date.now() - since.getTime()) / (365 * dayMs);
expect(years).toBeGreaterThan(4.9);
});
});
@@ -14,6 +14,26 @@ vi.mock("@/lib/db", () => ({
},
}));
// The per-type latest-readings read is one LATERAL query in Postgres; here
// it is served from the `measurement.findMany` fixtures, newest first as the
// query orders them, so the suite keeps describing its data as rows.
vi.mock("@/lib/insights/recent-readings", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/recent-readings")>();
const { prisma: db } = await import("@/lib/db");
return {
...actual,
readLatestReadingsPerType: async () =>
[
...((await db.measurement.findMany({} as never)) as Array<{
type: string;
value: number;
measuredAt: Date;
}>),
].reverse(),
};
});
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
@@ -81,6 +81,26 @@ vi.mock("@/lib/db", () => ({
},
}));
// The per-type latest-readings read is one LATERAL query in Postgres; here
// it is served from the `measurement.findMany` fixtures, newest first as the
// query orders them, so the suite keeps describing its data as rows.
vi.mock("@/lib/insights/recent-readings", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/recent-readings")>();
const { prisma: db } = await import("@/lib/db");
return {
...actual,
readLatestReadingsPerType: async () =>
[
...((await db.measurement.findMany({} as never)) as Array<{
type: string;
value: number;
measuredAt: Date;
}>),
].reverse(),
};
});
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
@@ -19,9 +19,10 @@ function read(rel: string): string {
describe("prompt reads exclude tombstoned rows", () => {
it("general-status filters its measurement read", () => {
const src = read("../general-status.ts");
expect(src).toMatch(
/measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/,
// The overview reads through the per-type latest-readings query.
expect(read("../general-status.ts")).toMatch(/readLatestReadingsPerType\(/);
expect(read("../recent-readings.ts")).toMatch(
/readLatestReadingsPerType[\s\S]*?m\."deleted_at" IS NULL/,
);
});
@@ -185,8 +185,11 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
dateOfBirth: null,
gender: null,
} as never);
// pulse-status reads PULSE first, then RESTING_HEART_RATE.
// pulse-status reads PULSE first, then RESTING_HEART_RATE. The latest
// 365 PULSE readings reach back less than 30 days here, so the PULSE
// read takes the 30-day window as a second query.
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(pulseRecords as never)
.mockResolvedValueOnce(pulseRecords as never)
.mockResolvedValueOnce(restingRecords as never)
.mockResolvedValue([] as never);
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { findMany, annotate } = vi.hoisted(() => ({
findMany: vi.fn(),
annotate: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany } } }));
vi.mock("@/lib/logging/context", () => ({ annotate }));
import { readRecentReadings, RECENT_READINGS } from "../recent-readings";
const now = new Date("2026-10-20T12:00:00Z");
const minutesAgo = (n: number) => new Date(now.getTime() - n * 60_000);
beforeEach(() => {
findMany.mockReset();
annotate.mockReset();
});
describe("readRecentReadings", () => {
it("keeps the latest 365 when they reach back 30 days", async () => {
// One a day: 365 readings span a year.
const rows = Array.from({ length: RECENT_READINGS }, (_, i) => ({
value: 70,
measuredAt: minutesAgo(i * 1440),
}));
findMany.mockResolvedValueOnce(rows);
const out = await readRecentReadings("u1", "PULSE", now);
expect(findMany).toHaveBeenCalledTimes(1);
expect(out[0].measuredAt < out[out.length - 1].measuredAt).toBe(true);
});
it("reads the last 30 days by time when 365 readings are a few hours", async () => {
const dense = Array.from({ length: RECENT_READINGS }, (_, i) => ({
value: 70,
measuredAt: minutesAgo(i),
}));
const month = Array.from({ length: 30 * 24 }, (_, i) => ({
value: 70,
measuredAt: minutesAgo(i * 60),
}));
findMany.mockResolvedValueOnce(dense).mockResolvedValueOnce(month);
const out = await readRecentReadings("u1", "PULSE", now);
expect(findMany).toHaveBeenCalledTimes(2);
const window = findMany.mock.calls[1][0] as {
where: { measuredAt: { gte: Date }; deletedAt: null };
};
expect(window.where.deletedAt).toBeNull();
expect(window.where.measuredAt.gte.toISOString()).toBe(
"2026-09-20T12:00:00.000Z",
);
expect(out).toHaveLength(month.length);
expect(annotate).not.toHaveBeenCalled();
});
it("keeps fewer than 365 readings whole", async () => {
findMany.mockResolvedValueOnce([{ value: 1, measuredAt: minutesAgo(5) }]);
expect(await readRecentReadings("u1", "PULSE", now)).toHaveLength(1);
expect(findMany).toHaveBeenCalledTimes(1);
});
});
@@ -1,11 +1,14 @@
/**
* A chronic condition does not zero-fill the symptom-severity series.
* A chronic condition does not zero-fill the symptom-severity series, but its
* logged days stay in it.
*
* The series fills healthy days with 0 across an episode's span. A chronic
* condition has no end, so its span covered the whole window and the engine
* correlated a near-constant run of zeros. Only episodes that can put the
* account into Rest Mode carry a span; the fake store below honours the
* lifecycle filter the way Postgres would.
* account into Rest Mode carry a span. The first cut of that fix dropped the
* chronic episode from the read altogether, which also dropped every day the
* person had logged for it: a chronic-only account had no channel at all.
* The fake store honours the episode filter the way Postgres would.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -15,7 +18,11 @@ type Episode = {
onsetAt: Date;
resolvedAt: Date | null;
};
const store: { episodes: Episode[] } = { episodes: [] };
type DayLog = { episodeId: string; date: string; functionalImpact: number };
const store: { episodes: Episode[]; dayLogs: DayLog[] } = {
episodes: [],
dayLogs: [],
};
vi.mock("@/lib/db", () => ({
prisma: {
@@ -29,7 +36,18 @@ vi.mock("@/lib/db", () => ({
),
),
},
illnessDayLog: { findMany: vi.fn(async () => []) },
illnessDayLog: {
findMany: vi.fn(
async (args: { where: { episodeId: { in: string[] } } }) =>
store.dayLogs
.filter((d) => args.where.episodeId.in.includes(d.episodeId))
.map((d) => ({
date: d.date,
functionalImpact: d.functionalImpact,
symptomLinks: [],
})),
),
},
},
}));
@@ -38,12 +56,16 @@ import { fetchSymptomSeries } from "../correlation-channel-series";
const DAY = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - 60 * DAY);
const dayKeyAgo = (days: number) =>
new Date(Date.now() - days * DAY).toISOString().slice(0, 10);
beforeEach(() => {
store.episodes = [];
store.dayLogs = [];
});
describe("fetchSymptomSeries", () => {
it("has no series for a chronic condition alone", async () => {
it("has no zero-filled series for a chronic condition without logs", async () => {
store.episodes = [
{
id: "chronic",
@@ -75,4 +97,24 @@ describe("fetchSymptomSeries", () => {
expect(series.points.length).toBeGreaterThanOrEqual(6);
expect(series.points.length).toBeLessThanOrEqual(8);
});
it("keeps the logged days of a chronic condition, without zero-fill", async () => {
store.episodes = [
{
id: "chronic",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date(Date.now() - 900 * DAY),
resolvedAt: null,
},
];
store.dayLogs = [
{ episodeId: "chronic", date: dayKeyAgo(10), functionalImpact: 3 },
{ episodeId: "chronic", date: dayKeyAgo(5), functionalImpact: 1 },
];
const series = await fetchSymptomSeries("user-1", "UTC", since);
expect(series.points).toEqual([
{ day: dayKeyAgo(10), value: 3 },
{ day: dayKeyAgo(5), value: 1 },
]);
});
});
+3 -8
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
@@ -238,6 +239,7 @@ export async function prepareBloodPressureStatusForUser(
.findMany({
where: {
userId,
deletedAt: null,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
@@ -457,14 +459,7 @@ export async function prepareBloodPressureStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries (~3 months) so power users don't pull
// unbounded rows before the bucket-series budget runs.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
+18 -18
View File
@@ -19,7 +19,7 @@ import { Buffer } from "node:buffer";
import { decryptBytes, encryptBytes } from "@/lib/crypto";
import { INSIGHTS_CACHED_TEXT_AAD } from "@/lib/crypto/encrypted-columns";
import { getEvent } from "@/lib/logging/context";
import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy";
/** The columns a reader selects to open the cache. */
export const INSIGHTS_CACHE_SELECT = {
@@ -76,10 +76,14 @@ export const INSIGHTS_CACHE_PRESENT_WHERE = {
/**
* The cached payload as text, or null when there is none.
*
* A legacy readable value wins when one is present: from v1.43.7 on nothing
* writes that column, so a row holding both can only come from a writer of the
* previous release still running after the upgrade, and its value is the
* newer one. The backfill seals it on its next pass.
* The ciphertext is read first, as every sealed column is read
* (`readSealedOrLegacy`); the readable column only when the row holds no
* ciphertext. A row holding both can come from a writer of the previous
* release still running after the upgrade; its readable value may be the
* newer one, and the free-text backfill seals it over the old ciphertext on
* its next pass. Until then the reader serves the sealed one: an older
* briefing for a while, never a readable value preferred over a protected
* one.
*
* A sealed value that does not open reads as an empty cache with a warning on
* the event. It is a cache: the next warm overwrites it under the active key,
@@ -89,17 +93,13 @@ export function openInsightsCachedText(
row: InsightsCacheColumns | null | undefined,
): string | null {
if (!row) return null;
if (typeof row.insightsCachedText === "string") return row.insightsCachedText;
const sealed = row.insightsCachedTextEncrypted;
if (!sealed || sealed.byteLength === 0) return null;
try {
return decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString(
"utf8",
);
} catch (err) {
getEvent()?.addWarning(
`insights cache did not open: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
return readSealedOrLegacy({
sealed: row.insightsCachedTextEncrypted,
legacy: row.insightsCachedText,
open: (sealed) =>
decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString(
"utf8",
),
onUndecryptable: warnAndDrop("insights cache did not open"),
});
}
+18 -18
View File
@@ -20,7 +20,7 @@
* fabricating a constant.
*/
import { prisma } from "@/lib/db";
import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode";
import { triggersRestMode } from "@/lib/illness/rest-mode";
import {
buildComplianceMedicationContext,
COURSES_COMPLIANCE_SELECT,
@@ -58,6 +58,7 @@ import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import {
reconstructSleepSessions,
daySleepMinutes,
pickMainNightAndNaps,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
@@ -150,11 +151,13 @@ export async function fetchComplianceSeries(
}
/**
* v1.21.0 (FDREXTEND) — build the user's SYMPTOM_SEVERITY daily series in the
* `outcome` role (callers that need the behaviour role re-tag the returned
* series — the points are role-invariant). Reads every in-window illness episode
* that is not a chronic condition + its day-logs; the builder zero-fills healthy
* days ONLY across those spans, so a user with no such episode yields an empty
* Build the user's SYMPTOM_SEVERITY daily series in the `outcome` role
* (callers that need the behaviour role re-tag the returned series — the
* points are role-invariant). Reads every in-window illness episode and its
* day-logs. Only episodes that can put the account into Rest Mode zero-fill
* their span: a chronic condition has no end, so its span would fill every
* day of the window and hand the engine a near-constant series. Its logged
* days still count, as real readings. No episode and no log yields an empty
* series that degrades to absent.
*/
export async function fetchSymptomSeries(
@@ -166,17 +169,13 @@ export async function fetchSymptomSeries(
const episodes = await prisma.illnessEpisode.findMany({
// An episode overlaps the window when it onset before `now` and either is
// ongoing or resolved at/after the window start.
// A chronic condition has no end, so its span would zero-fill every day
// of the window and hand the engine a near-constant series. Only the
// episodes that can put the account into Rest Mode carry a span.
where: {
userId,
deletedAt: null,
onsetAt: { lte: now },
OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }],
...REST_MODE_EPISODE_WHERE,
},
select: { id: true, onsetAt: true, resolvedAt: true },
select: { id: true, onsetAt: true, resolvedAt: true, lifecycle: true },
});
if (episodes.length === 0) {
return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] };
@@ -215,10 +214,9 @@ export async function fetchSymptomSeries(
dayLogs.push({ day: row.date, impact: maxSeverity });
}
const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({
onsetAt: e.onsetAt,
resolvedAt: e.resolvedAt,
}));
const spans: SymptomEpisodeSpan[] = episodes
.filter(triggersRestMode)
.map((e) => ({ onsetAt: e.onsetAt, resolvedAt: e.resolvedAt }));
return buildSymptomSeverityDailySeries({
dayLogs,
@@ -834,11 +832,13 @@ function buildSleepDailySeries(
byNight.set(s.night, list);
}
// A day's sleep is the main night plus its naps (`daySleepMinutes`), the
// total the series route and the sleep score read.
const points: DailySeriesPoint[] = [];
for (const [night, nightSessions] of byNight) {
const { main } = pickMainNightAndNaps(nightSessions);
if (!main) continue;
points.push({ day: night, value: main.asleepMinutes });
const day = pickMainNightAndNaps(nightSessions);
if (!day.main && day.naps.length === 0) continue;
points.push({ day: night, value: daySleepMinutes(day) });
}
return points.sort((a, b) => (a.day < b.day ? -1 : 1));
}
+12 -10
View File
@@ -120,9 +120,11 @@ function dayKey(at: Date, tz: string): string {
/**
* Build the daily SYMPTOM_SEVERITY series (rides both roles; this returns it in
* the requested `role`). Healthy days are zero-filled — but ONLY across the
* span of the user's real episodes intersected with `[windowStart, windowEnd]`,
* so a user with no episodes yields an EMPTY series (the channel degrades to
* absent, never a spurious all-zero constant).
* span of the episodes passed in, intersected with `[windowStart, windowEnd]`.
* Logged days always count, inside a span or not, so an episode that carries
* no span (a chronic condition) still contributes its real readings. No span
* and no log yields an EMPTY series (the channel degrades to absent, never a
* spurious all-zero constant).
*
* Algorithm:
* 1. Compute the set of in-window calendar days covered by ANY episode span
@@ -134,8 +136,8 @@ function dayKey(at: Date, tz: string): string {
* on a day → the MAX, the most-symptomatic reading).
*
* The result is a dense-within-episode-spans series with genuine 0↔>0 variance
* when the user had symptomatic days, and NOTHING at all when they had no
* episodes. Pure.
* when the user had symptomatic days, and NOTHING at all when there is neither
* a span nor a logged day. Pure.
*/
export function buildSymptomSeverityDailySeries(args: {
dayLogs: SymptomDayLogRow[];
@@ -171,11 +173,6 @@ export function buildSymptomSeverityDailySeries(args: {
}
}
// If no episode put a single day on the board, the channel is absent.
if (byDay.size === 0) {
return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] };
}
// Step 3 — overlay logged impacts (MAX per day). A logged day outside every
// episode span is still honoured (it is, by definition, a symptomatic day the
// user recorded), so it joins the series even if the span walk missed it.
@@ -186,6 +183,11 @@ export function buildSymptomSeverityDailySeries(args: {
byDay.set(log.day, prev == null ? log.impact : Math.max(prev, log.impact));
}
// No span and no logged day: the channel is absent.
if (byDay.size === 0) {
return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] };
}
const points: DailySeriesPoint[] = [...byDay.entries()]
.map(([day, value]) => ({ day, value }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
+4 -16
View File
@@ -53,6 +53,7 @@ import {
import { readDayMeanSeries, type BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import { linearFit } from "@/lib/stats/linear-fit";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
@@ -154,22 +155,9 @@ export function fitOls(xs: number[], ys: number[]): OlsFit | null {
const n = xs.length;
if (n < 3 || ys.length !== n) return null;
const sumX = xs.reduce((s, x) => s + x, 0);
const sumY = ys.reduce((s, y) => s + y, 0);
const meanX = sumX / n;
const meanY = sumY / n;
let sxx = 0;
let sxy = 0;
for (let i = 0; i < n; i++) {
const dx = xs[i] - meanX;
sxx += dx * dx;
sxy += dx * (ys[i] - meanY);
}
if (sxx === 0) return null; // all x identical → no slope
const slope = sxy / sxx;
const intercept = meanY - slope * meanX;
const fit = linearFit(xs, ys);
if (!fit) return null; // all x identical → no slope
const { slope, intercept, meanX, meanY, sxx } = fit;
let ssRes = 0;
let ssTot = 0;
+6 -8
View File
@@ -1021,7 +1021,7 @@ async function readMoodFeature(
//
// Coverage-fallback: when raw entries exist but the rollup tier is
// still empty (legacy account before boot-time backfill caught up),
// we fall back to a bounded 1-year raw walk and fire the warm-up so
// we fall back to a raw walk over the same window and fire the warm-up so
// the next request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
@@ -1062,15 +1062,13 @@ async function readMoodFeature(
});
moodLatestScore = latestEntry?.score ?? null;
} else {
// Coverage-fallback. Bounded 1-year walk (instead of the legacy
// unbounded `findMany`) so even a cache miss is capped.
const oneYearAgo = laterDate(
new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
historyFloor,
);
// Coverage-fallback over the same window the rollup read covers
// (`moodSince`, five years). A one-year fallback answered the same
// question with a different history while the tier was warming up;
// mood entries are a few a day, so five years stays a bounded read.
const moodEntriesRaw = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, moodLoggedAt: { gte: oneYearAgo } },
where: { userId, deletedAt: null, moodLoggedAt: { gte: moodSince } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true, date: true },
});
+9 -31
View File
@@ -1,4 +1,6 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import { readLatestReadingsPerType } from "@/lib/insights/recent-readings";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -52,7 +54,7 @@ import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
const MEASUREMENT_TYPES = measurementTypeEnum.options;
// The general overview folds its per-type graded series in memory from a
// single bounded read (take 5000, desc) rather than per-type rollup-tier
// single bounded read (the latest 365 per type) rather than per-type rollup-tier
// reads: a many-metric account would otherwise fan out ~15 metrics × 3
// rollup round-trips per render. `degradeStatusSnapshotToBudget` already
// caps the multi-metric snapshot, so the bounded in-memory fold is the
@@ -168,28 +170,11 @@ export async function prepareGeneralStatusForUser(
},
});
// v1.4.28 — cap the snapshot input. General-status pulls
// every supported measurement type; without a cap the read scales
// linearly with account density.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: {
in: [...MEASUREMENT_TYPES],
},
// Soft-deleted rows must never reach the prompt snapshot.
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 5000,
select: {
type: true,
value: true,
measuredAt: true,
},
})
.then((rows) => rows.reverse());
// The latest 365 readings of each type, bounded per type so a dense
// stream cannot crowd the others out (`readLatestReadingsPerType`).
const measurements = await readLatestReadingsPerType(userId, [
...MEASUREMENT_TYPES,
]);
const now = new Date();
@@ -309,14 +294,7 @@ export async function prepareGeneralStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodRecords = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
+6 -15
View File
@@ -60,6 +60,7 @@ import {
import { readingsMean } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { userDayKey } from "@/lib/tz/format";
import { linearFit } from "@/lib/stats/linear-fit";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -205,21 +206,11 @@ function foldInto(map: Map<string, Agg>, key: string, value: number): void {
* over the order the readings landed.
*/
function leastSquaresSlope(values: number[]): number | null {
const n = values.length;
if (n < 2) return null;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += values[i];
sumXY += i * values[i];
sumXX += i * i;
}
const denom = n * sumXX - sumX * sumX;
if (denom === 0) return null;
return round((n * sumXY - sumX * sumY) / denom, 4);
const fit = linearFit(
values.map((_, i) => i),
values,
);
return fit ? round(fit.slope, 4) : null;
}
/**
+4 -13
View File
@@ -22,6 +22,7 @@
* the card's insufficient-data state.
*/
import { prisma } from "@/lib/db";
import { readRecentReadings } from "@/lib/insights/recent-readings";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
@@ -293,7 +294,7 @@ export async function generateMetricStatus(args: {
let measurements: Array<{ measuredAt: Date }> = [];
if (isSleep) {
// The latest 365 nights, the sleep twin of the 365 latest readings the
// other types read. A cut on stage rows instead (2190 of them) reached
// other types read (`readRecentReadings`). A cut on stage rows instead (2190 of them) reached
// back a few weeks on a stage-rich watch and began mid-night.
const nights = (
await readSleepNights(
@@ -308,18 +309,8 @@ export async function generateMetricStatus(args: {
}
measurements = nights.map((n) => ({ measuredAt: n.measuredAt }));
} else {
const rows = await prisma.measurement
.findMany({
where: {
userId: args.userId,
type: meta.measurementType,
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365,
select: { value: true, measuredAt: true },
})
.then((r) => r.reverse());
// The latest 365 readings, never less than the last 30 days.
const rows = await readRecentReadings(args.userId, meta.measurementType);
for (const m of rows) {
points.push({
measuredAt: m.measuredAt,
+1
View File
@@ -241,6 +241,7 @@ export async function prepareMoodStatusForUser(
.findMany({
where: {
userId,
deletedAt: null,
},
orderBy: { date: "desc" },
take: 90,
+6 -27
View File
@@ -1,4 +1,6 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import { readRecentReadings } from "@/lib/insights/recent-readings";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
@@ -184,25 +186,9 @@ export async function preparePulseStatusForUser(
// estimate for non-Berlin users. `userTz` was already resolved above for
// the cache day-key.
// v1.4.28 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further but the unbounded findMany was
// pulling tens of thousands of rows for Apple-Health-rich accounts
// before the budget call even started.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: "PULSE",
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365,
select: {
value: true,
measuredAt: true,
},
})
.then((rows) => rows.reverse());
// The latest 365 readings, and never less than the last 30 days: a dense
// pulse stream holds 365 readings in a few hours (`readRecentReadings`).
const measurements = await readRecentReadings(userId, "PULSE");
// v1.15.12 — the RESTING band is judged against the RESTING series,
// not the raw PULSE stream (Apple fills PULSE with workout HR). Pull
@@ -252,14 +238,7 @@ export async function preparePulseStatusForUser(
// Fetch mood context (optional — for enrichment only). Cap at 90
// entries (~3 months); the bucket-series budget then summarises
// further. v1.4.28 FB-D2 — prevent unbounded reads for power users.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodPoints = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
+104
View File
@@ -0,0 +1,104 @@
/**
* The raw readings a per-metric status snapshot reads: the latest 365, and
* never less than the last 30 days.
*
* The snapshots used to read `take: 365` rows. For a type read a few times a
* day that reaches back months; for a dense stream (a watch's pulse every
* few minutes, hourly folds of an imported history) it reached back hours,
* and the "last 30 days" figures built on it (in-target share, the resting
* proxy, the latest day against the one before) were computed over a sliver
* of one day. When the latest 365 readings do not reach back 30 days, the
* last 30 days are read by time instead, bounded by {@link DENSE_READ_CAP}
* so a pathological stream cannot take the heap; hitting the bound is
* annotated, never silent. The longer tail of every snapshot comes from the
* rollup tier, not from here.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
export const RECENT_READINGS = 365;
export const RECENT_MIN_DAYS = 30;
/** A reading a minute for 30 days is 43 200; this is above any real stream. */
export const DENSE_READ_CAP = 60_000;
const DAY_MS = 24 * 60 * 60 * 1000;
export interface RecentReading {
value: number;
measuredAt: Date;
}
/** Oldest first. */
export async function readRecentReadings(
userId: string,
type: MeasurementType,
now: Date = new Date(),
): Promise<RecentReading[]> {
const latest = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "desc" },
take: RECENT_READINGS,
select: { value: true, measuredAt: true },
});
const since = new Date(now.getTime() - RECENT_MIN_DAYS * DAY_MS);
const oldest = latest.at(-1);
if (
latest.length < RECENT_READINGS ||
!oldest ||
oldest.measuredAt <= since
) {
return latest.reverse();
}
const window = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "desc" },
take: DENSE_READ_CAP,
select: { value: true, measuredAt: true },
});
if (window.length === DENSE_READ_CAP) {
annotate({
action: { name: "insights.recent-readings.capped" },
meta: { type, cap: DENSE_READ_CAP },
});
}
return window.reverse();
}
/**
* The latest `perType` readings of every type in `types`, oldest first, for
* the multi-metric overview. It read the newest 5 000 rows across all types
* at once, so a dense stream (pulse every few minutes) filled the whole read
* within days and every other type fell out of the overview. One index
* descent per type now bounds each type on its own. The driving relation is
* aliased `mt`, the way `readLatestEver` aliases it, so the i18n call-site
* scanner does not read the SQL as a translation call.
*/
export async function readLatestReadingsPerType(
userId: string,
types: readonly MeasurementType[],
perType: number = RECENT_READINGS,
): Promise<Array<RecentReading & { type: MeasurementType }>> {
if (types.length === 0) return [];
const rows = await prisma.$queryRaw<
Array<{ type: MeasurementType; value: number; measured_at: Date }>
>`
SELECT mt."type" AS "type", x."value", x."measured_at"
FROM unnest(${[...types]}::text[]) AS mt("type")
CROSS JOIN LATERAL (
SELECT m."value", m."measured_at"
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = mt."type"::measurement_type
AND m."deleted_at" IS NULL
ORDER BY m."measured_at" DESC
LIMIT ${perType}
) x
ORDER BY x."measured_at" ASC
`;
return rows.map((r) => ({
type: r.type,
value: Number(r.value),
measuredAt: new Date(r.measured_at),
}));
}
+2 -8
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
@@ -356,14 +357,7 @@ export async function prepareWeightStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
@@ -26,4 +26,12 @@ describe("importValue", () => {
expect(importValue(1e-7)).toBe(0);
expect(importValue(1e22)).toBe(1e22);
});
it("rounds to another precision on request, halves away from zero", () => {
expect(importValue(72.34999, 1)).toBe(72.3);
expect(importValue(1.0005, 3)).toBe(1.001);
expect(importValue(-2.5, 0)).toBe(-3);
expect(importValue(0.30000000000000004, 3)).toBe(0.3);
expect(() => importValue(1, 7)).toThrow(RangeError);
});
});
+17 -10
View File
@@ -9,20 +9,27 @@
* also mis-rounds on halves (`(1.005).toFixed(2)` is `"1.00"`, because the
* double below 1.005 is what gets rounded).
*
* Two decimals, rounded half away from zero on the decimal the value prints
* as, so the result is the double nearest to a two-place decimal and prints
* cleanly. Two places is the precision every provider client already stored;
* this keeps it. `src/__tests__/provider-rounding-guard.test.ts` keeps
* `toFixed` out of the provider directories.
* Two decimals by default, rounded half away from zero on the decimal the
* value prints as, so the result is the double nearest to a short decimal
* and prints cleanly. Two places is the precision every provider client
* already stored; this keeps it. A source whose values need another
* precision (a nutrient in grams to the milligram) passes `places` rather
* than rounding privately. `src/__tests__/provider-rounding-guard.test.ts`
* keeps `toFixed` and hand-rolled `Math.round(x * 10^n) / 10^n` out of the
* provider and import directories.
*/
export function importValue(n: number): number {
export function importValue(n: number, places = 2): number {
if (!Number.isFinite(n)) return n;
if (!Number.isInteger(places) || places < 0 || places > 6) {
throw new RangeError(`importValue: places must be 0..6, got ${places}`);
}
const magnitude = Math.abs(n);
const text = String(magnitude);
const scale = 10 ** places;
// A value printed in exponent form is below a millionth or beyond 1e21;
// shifting its text would not parse, and plain arithmetic is exact enough.
const hundredths = text.includes("e")
? Math.round(magnitude * 100)
: Math.round(Number(`${text}e2`));
return (Math.sign(n) * hundredths) / 100 || 0;
const units = text.includes("e")
? Math.round(magnitude * scale)
: Math.round(Number(`${text}e${places}`));
return (Math.sign(n) * units) / scale || 0;
}
@@ -121,6 +121,13 @@ vi.mock("@/lib/db", () => ({
vaccinationRecord: table("vaccinationRecord"),
},
}));
// The search presence probe is one index lookup per type in Postgres; the
// suite states which types hold a live reading through this stub.
vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/summary-reads")>()),
readLatestEver: vi.fn(async () => []),
}));
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(
async (
@@ -42,6 +42,13 @@ vi.mock("@/lib/db", () => ({
},
}));
// The search presence probe is one index lookup per type in Postgres; the
// suite states which types hold a live reading through this stub.
vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/summary-reads")>()),
readLatestEver: vi.fn(async () => []),
}));
import { MCP_TOOLS } from "../tools";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
+16 -8
View File
@@ -60,6 +60,13 @@ vi.mock("@/lib/db", () => ({
ecgRecording: { findMany: vi.fn(async () => []) },
},
}));
// The search presence probe is one index lookup per type in Postgres; the
// suite states which types hold a live reading through this stub.
vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/summary-reads")>()),
readLatestEver: vi.fn(async () => []),
}));
// v1.24 — the operational reads delegate to existing server-authoritative
// engines; stub them so the registry-wide loops never reach a real engine.
vi.mock("@/lib/medications/scheduling/next-due", () => ({
@@ -157,6 +164,7 @@ import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { listTargetsBySource } from "@/lib/links";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import type { McpAuthContext } from "../auth";
import { readLatestEver } from "@/lib/dashboard/summary-reads";
const CTX: McpAuthContext = {
userId: "user-1",
@@ -1229,8 +1237,8 @@ describe("v1.25 clinical signals on the MCP surface", () => {
});
it("search surfaces a present clinical signal as metric:<KEY>", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{ type: "GRIP_STRENGTH" },
vi.mocked(readLatestEver).mockResolvedValue([
{ type: "GRIP_STRENGTH", value: 1, measured_at: new Date() },
] as never);
const result = (await tool("search").run(CTX, { query: "grip" })) as {
results: Array<{ id: string; title: string; url: string }>;
@@ -1242,7 +1250,7 @@ describe("v1.25 clinical signals on the MCP surface", () => {
});
it("search omits a clinical signal with no recorded data", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(readLatestEver).mockResolvedValue([] as never);
const result = (await tool("search").run(CTX, { query: "grip" })) as {
results: Array<{ id: string }>;
};
@@ -1250,7 +1258,7 @@ describe("v1.25 clinical signals on the MCP surface", () => {
});
it("fetch hydrates a clinical signal via the baseline read (not the Coach path)", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(readLatestEver).mockResolvedValue([] as never);
vi.mocked(getMetricBaseline).mockResolvedValue({
present: true,
metric: "Grip strength",
@@ -1292,8 +1300,8 @@ describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5
});
it("search surfaces a present metric-status-only id (undiscoverable before this wave)", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{ type: "WRIST_TEMPERATURE" },
vi.mocked(readLatestEver).mockResolvedValue([
{ type: "WRIST_TEMPERATURE", value: 1, measured_at: new Date() },
] as never);
const result = (await tool("search").run(CTX, { query: "wrist" })) as {
results: Array<{ id: string; title: string }>;
@@ -1303,7 +1311,7 @@ describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5
});
it("fetch hydrates a metric-status-only id via the baseline read", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(readLatestEver).mockResolvedValue([] as never);
vi.mocked(getMetricBaseline).mockResolvedValue({
present: true,
metric: "Wrist skin temperature",
@@ -1387,7 +1395,7 @@ describe("nutrients on search / fetch (v1.30 coverage review G1)", () => {
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(readLatestEver).mockResolvedValue([] as never);
});
it("search resolves 'water' to nutrient:water when the user has logged it", async () => {
+8 -8
View File
@@ -20,6 +20,7 @@
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { readLatestEver } from "@/lib/dashboard/summary-reads";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { annotate } from "@/lib/logging/context";
import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions";
@@ -584,14 +585,13 @@ function searchAndFetchTools(): McpToolDefinition[] {
// v1.41.2 — live rows only: a deleted reading is not a signal the
// record holds, and the filter lets the probe use the live-rows
// partial index instead of walking the tombstones.
const discoverablePresent = await prisma.measurement.groupBy({
by: ["type"],
where: {
userId: ctx.userId,
type: { in: discoverableSignals.map((s) => s.measurementType) },
deletedAt: null,
},
});
// A presence probe is one index lookup per type (`readLatestEver`
// drops a type with no live reading), not a grouping of every row.
const discoverablePresent = await readLatestEver(
prisma,
ctx.userId,
discoverableSignals.map((s) => s.measurementType),
);
const presentTypes = new Set(discoverablePresent.map((r) => r.type));
for (const sig of discoverableSignals) {
if (!presentTypes.has(sig.measurementType)) continue;
+12 -7
View File
@@ -9,17 +9,20 @@
* Two modes:
* - `dayKey` set → drill-down: the night's stage SEGMENTS (the canonical
* source's per-stage spans), read exactly as the night view reads them.
* - no `dayKey` → one synthetic row per night, value = the main sleep's TIME
* ASLEEP in minutes (the canonical unit), with the per-night stage
* breakdown, nap count and awakenings in the row so the UI can caption the
* night without a second fetch.
* - no `dayKey` → one synthetic row per night, value = the day's TIME ASLEEP
* in minutes (the canonical unit): the main night plus that day's naps,
* `daySleepMinutes`, the same total the series and the sleep score read.
* The main night alone rides as `mainAsleepMinutes`, beside its stage
* breakdown, nap count and awakenings, so the UI can caption the night
* without a second fetch.
*
* The nights are paged in Postgres (`sleepNightPage`) and only the stage rows
* of the page's nights are read. Every page night gets a row, so a page is as
* long as its limit and `meta.total` counts exactly the rows a full walk sees.
* A day whose only sleep is daytime naps has no main night: its row carries
* 0 minutes asleep and its naps, as `/api/sleep/night` answers the same day
* with `main: null` and the naps.
* the naps' minutes as its value and `mainAsleepMinutes: null`, as
* `/api/sleep/night?date=` answers the same day with `main: null` and the
* naps.
*/
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
@@ -27,6 +30,7 @@ import { getUnitForType } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import {
daySleepMinutes,
pickMainNightAndNaps,
reconstructSleepSessions,
type SleepSession,
@@ -131,7 +135,8 @@ export async function sleepListResponse(
// Synthetic id mirrors the series route's `sleep:<night>` shape.
id: `sleep:${night}`,
type: "SLEEP_DURATION",
value: main ? Math.round(main.asleepMinutes) : 0,
value: Math.round(daySleepMinutes({ main, naps })),
mainAsleepMinutes: main ? Math.round(main.asleepMinutes) : null,
unit: dayUnit,
source: main?.source ?? naps[0]?.source ?? "MANUAL",
measuredAt: (main?.end ?? lastNap?.end ?? spanEnd).toISOString(),
+45 -35
View File
@@ -15,6 +15,7 @@ import {
SESSION_GAP_MS,
reconstructSleepNights,
reconstructSleepSessions,
separateNightsAndNaps,
type SleepNight,
type SleepSession,
} from "@/lib/analytics/sleep-night";
@@ -117,38 +118,44 @@ export async function readNightSessions(
return { sessions, rows };
}
/** How many nights one step of the latest-night walk reads. */
const LATEST_WALK_STEP = 14;
/**
* The wake day of the most recent night that holds sleep, or null. Found
* from the latest asleep row: the night holding it is the newest one, and
* its sessions lie within the one-night reach of that row.
* The wake day of the most recent night with a main sleep, or, on an account
* that has only ever recorded naps, the most recent day of naps; null with no
* sleep at all. A day of naps alone is a real day of sleep, but it is not
* "last night": answering it would leave a hypnogram with no night to draw.
* Postgres names the nights newest first and the walk reconstructs them a
* step at a time until one holds a main sleep, so a run of nap-only days
* however long is stepped over and the heap holds a step's rows.
*/
export async function latestSleepNight(
export async function latestMainSleepNight(
userId: string,
tz: string,
priorityJson: unknown,
): Promise<string | null> {
const latest = await prisma.measurement.findFirst({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
value: { gt: 0 },
OR: [{ sleepStage: null }, { sleepStage: { in: [...ASLEEP_STAGES] } }],
},
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
if (!latest) return null;
const at = latest.measuredAt.getTime();
const rows = await readSleepRows(
userId,
new Date(at - NIGHT_READ_REACH_MS),
new Date(at + NIGHT_READ_REACH_MS),
);
const scorable = reconstructSleepSessions(rows, tz, priorityJson).filter(
(s) => s.asleepMinutes > 0,
);
return scorable.at(-1)?.night ?? null;
let latestNapDay: string | null = null;
for (let offset = 0; ; offset += LATEST_WALK_STEP) {
const page = await sleepNightPage(userId, tz, {
sortDir: "desc",
limit: LATEST_WALK_STEP,
offset,
});
if (page.nights.length === 0) return latestNapDay;
const keys = new Set(page.nights.map((n) => n.night));
const rows = await readNightSpanRows(userId, page.nights);
const days = separateNightsAndNaps(
reconstructSleepSessions(rows, tz, priorityJson).filter((s) =>
keys.has(s.night),
),
);
for (let i = days.length - 1; i >= 0; i -= 1) {
if (days[i].main) return days[i].night;
latestNapDay ??= days[i].night;
}
if (page.nights.length < LATEST_WALK_STEP) return latestNapDay;
}
}
/** One night of a page: its wake day and the span its sessions cover. */
@@ -164,13 +171,16 @@ export interface SleepNightSpan {
* The stage rows are cut into sessions the way `reconstructSleepSessions`
* cuts them: ordered by start (`measured_at` minus the minutes), a new
* session wherever a row starts more than `SESSION_GAP_MS` after every
* earlier row has ended. A session counts when it holds an asleep row (an
* `ASLEEP_STAGES` stage, or a stage-less row, with minutes), and its night is
* the local day its last row ends on: the reconstruction's own rule, so a
* page night and a reconstructed night name the same day. Nights are grouped,
* sorted and cut by `limit` / `offset`; `total` counts the whole filtered set.
* `spanStart` / `spanEnd` bound the night's counting sessions, so the caller
* reads exactly their rows.
* earlier row has ended. A session's night is the local day its last row
* ends on: the reconstruction's own rule, so a page night and a
* reconstructed night name the same day. A night counts when one of its
* sessions holds an asleep row (an `ASLEEP_STAGES` stage, or a stage-less
* row, with minutes). Nights are grouped, sorted and cut by `limit` /
* `offset`; `total` counts the whole filtered set. `spanStart` / `spanEnd`
* bound every session of the night, the in-bed-only ones included, because
* the reconstruction adds their in-bed time to the night: a span over the
* asleep sessions alone left an evening's in-bed block out of a chunked read
* that the one-night read, reaching a day and a half, counted.
*
* Before the cut, rows are folded into hour buckets by their end: min start,
* max end, any asleep. That cannot move a session boundary. Two rows whose
@@ -218,10 +228,9 @@ export async function sleepNightPage(
) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid
FROM o
), sess AS (
SELECT MIN(s) AS s, MAX(e) AS e
SELECT MIN(s) AS s, MAX(e) AS e, BOOL_OR(asleep) AS asleep
FROM g
GROUP BY sid
HAVING BOOL_OR(asleep)
), nights AS (
SELECT
to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night,
@@ -229,6 +238,7 @@ export async function sleepNightPage(
MAX(e) AS span_end
FROM sess
GROUP BY 1
HAVING BOOL_OR(asleep)
)
`;
const { rows, total } = await pageWithWindowTotal(
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
const { moodEntry } = vi.hoisted(() => ({
moodEntry: { findMany: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: { moodEntry } }));
import { readRecentMoodScores } from "../recent-scores";
describe("readRecentMoodScores", () => {
it("reads live entries only, newest first, and hands them back oldest first", async () => {
const newer = {
date: "2026-10-02",
score: 4,
moodLoggedAt: new Date("2026-10-02T08:00:00Z"),
};
const older = {
date: "2026-10-01",
score: 2,
moodLoggedAt: new Date("2026-10-01T08:00:00Z"),
};
moodEntry.findMany.mockResolvedValueOnce([newer, older]);
const rows = await readRecentMoodScores("u1", 30);
expect(moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "u1", deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
take: 30,
}),
);
expect(rows).toEqual([older, newer]);
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* The recent-mood context read shared by the per-metric status snapshots
* (pulse, weight, blood pressure, general). A deleted entry is a tombstone
* kept for sync and undo; it must never reach a model prompt or a summary,
* so the filter lives here rather than at each call site.
*/
import { prisma } from "@/lib/db";
export interface RecentMoodScore {
date: string;
score: number;
moodLoggedAt: Date;
}
/** Most recent `take` live entries, returned oldest first. */
export async function readRecentMoodScores(
userId: string,
take = 90,
): Promise<RecentMoodScore[]> {
const rows = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
take,
select: { date: true, score: true, moodLoggedAt: true },
});
return rows.reverse();
}
+1 -6
View File
@@ -354,11 +354,6 @@ const recordActivityResponse = z
.describe(
"How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.",
),
truncated: z
.boolean()
.describe(
"True when the read reached its row ceiling, so `entries` is the most recent activity inside the window rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest row read as the beginning. False means the list is everything inside the window.",
),
})
.meta({
id: "RecordActivity",
@@ -875,7 +870,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Account sharing"],
summary: "Who opened this record, and when",
description:
"Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Not delegable: a grant is not an introduction to the other people in the household.",
"Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Whether the list stopped at its row ceiling is `meta.truncated` (it was a `truncated` field in `data` before v1.43.7). Not delegable: a grant is not an introduction to the other people in the household.",
responses: {
...stdResponses,
"200": {
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more