diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3230be4c9..ee80675e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index b2667a7de..a71da0818 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}." diff --git a/messages/en.json b/messages/en.json index 3e30108aa..98a9f9034 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index a7e92f85e..e601d625c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}." diff --git a/messages/fr.json b/messages/fr.json index 2626bfb40..44e6024ed 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index 9f6082888..80fdced4b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/ko.json b/messages/ko.json index 291d55291..d224ba278 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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} 님만 할 수 있어요." diff --git a/messages/nl.json b/messages/nl.json index 21d713639..b6dd5fe1e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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}." diff --git a/messages/pl.json b/messages/pl.json index 4735fd572..5cd38bb74 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}." diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 5c9521e0e..af4918879 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -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, diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 6e13de3b0..57abb04a7 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -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 diff --git a/src/__tests__/latest-per-type-read-guard.test.ts b/src/__tests__/latest-per-type-read-guard.test.ts index a0e7eb796..4ebc51836 100644 --- a/src/__tests__/latest-per-type-read-guard.test.ts +++ b/src/__tests__/latest-per-type-read-guard.test.ts @@ -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 = { + "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( diff --git a/src/__tests__/linear-fit-single-implementation-guard.test.ts b/src/__tests__/linear-fit-single-implementation-guard.test.ts new file mode 100644 index 000000000..424ca3b43 --- /dev/null +++ b/src/__tests__/linear-fit-single-implementation-guard.test.ts @@ -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 = { + "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); + }); +}); diff --git a/src/__tests__/list-cap-reader-guard.test.ts b/src/__tests__/list-cap-reader-guard.test.ts new file mode 100644 index 000000000..3c6752832 --- /dev/null +++ b/src/__tests__/list-cap-reader-guard.test.ts @@ -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 = { + "/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("/api/allergies")', + ), + ).toBe(true); + // A plain `apiGet` reads `data` only and does not count. + expect( + clientPathPattern("/api/allergies").test( + 'apiGet("/api/allergies")', + ), + ).toBe(false); + // A longer path that shares the prefix does not count either. + expect( + clientPathPattern("/api/allergies").test( + 'apiGetList("/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); + } + }); +}); diff --git a/src/__tests__/provider-rounding-guard.test.ts b/src/__tests__/provider-rounding-guard.test.ts index 10fe0b655..6c4b279c2 100644 --- a/src/__tests__/provider-rounding-guard.test.ts +++ b/src/__tests__/provider-rounding-guard.test.ts @@ -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 = { + "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); }); }); diff --git a/src/__tests__/tombstone-read-guard.test.ts b/src/__tests__/tombstone-read-guard.test.ts new file mode 100644 index 000000000..58895e9a0 --- /dev/null +++ b/src/__tests__/tombstone-read-guard.test.ts @@ -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 = { + "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(); + 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([]); + }); +}); diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts index 8e781d144..d72b9db82 100644 --- a/src/app/api/account/activity/route.ts +++ b/src/app/api/account/activity/route.ts @@ -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, ); }); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 3d4bd5a0e..470c6362a 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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; diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts index 1a0cf88c6..b3eddcdb2 100644 --- a/src/app/api/insights/breathing-screening/route.ts +++ b/src/app/api/insights/breathing-screening/route.ts @@ -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 }, }, diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index c5e1f8f34..483f888ee 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -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"); diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 8c3f4a01d..0d909d975 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -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)) diff --git a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx index 2f9c1a86f..6849bfbf8 100644 --- a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx +++ b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx @@ -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( diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 722a0c42b..38bda883e 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -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); } diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx index 2698461ae..db42dbebf 100644 --- a/src/app/insights/ecg/__tests__/page.test.tsx +++ b/src/app/insights/ecg/__tests__/page.test.tsx @@ -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( diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index 174687e2b..10cc0205a 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -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 `` 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 diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 640c8db11..96c31223d 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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(); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 2b5641cb7..0e3db438a 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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(); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 4742525eb..1b273ee93 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -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() { - + + - apiGet(`/api/cycle/cycles?limit=${limit}`), + apiGetList(`/api/cycle/cycles?limit=${limit}`), staleTime: 60_000, }); } diff --git a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts index ae403d1b5..e447bc115 100644 --- a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts +++ b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts @@ -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; + }); }); diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index b4b69288a..562ba032b 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -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( + const { data: page, cap } = await apiGetList( `/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; } }, }); diff --git a/src/components/insights/__tests__/ecg-cross-link.test.tsx b/src/components/insights/__tests__/ecg-cross-link.test.tsx index 3f1bb8b0e..838b0cd7a 100644 --- a/src/components/insights/__tests__/ecg-cross-link.test.tsx +++ b/src/components/insights/__tests__/ecg-cross-link.test.tsx @@ -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( diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx index 8dff451da..198aeb846 100644 --- a/src/components/insights/__tests__/ecg-section.test.tsx +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -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( @@ -174,4 +178,18 @@ describe("", () => { 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"', + ); + }); }); diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx index 7de61bc59..223c6cb7d 100644 --- a/src/components/insights/__tests__/rhythm-events-card.test.tsx +++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx @@ -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( diff --git a/src/components/insights/ecg-cross-link.tsx b/src/components/insights/ecg-cross-link.tsx index 15174c2a7..d0d30407c 100644 --- a/src/components/insights/ecg-cross-link.tsx +++ b/src/components/insights/ecg-cross-link.tsx @@ -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 = { 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("/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; diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index 2741a7d8c..34e724bf3 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -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("/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({ ); })} + {/* The teaser keeps its newest few on purpose; the full list says + when the server stopped short of the history. */} + {limit == null && } ); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 4414792c7..c04feeb8f 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -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 diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index 6c953a550..92d7ee1da 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -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( + return await apiGetList( "/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({ ); })} + {/* Load-bearing regulatory disclaimer — permanent, non-dismissible. Plain React text children (no markdown library — XSS rule). */} diff --git a/src/components/insights/use-ecg-list.ts b/src/components/insights/use-ecg-list.ts new file mode 100644 index 000000000..f49491178 --- /dev/null +++ b/src/components/insights/use-ecg-list.ts @@ -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("/api/insights/ecg"); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + enabled: enabled && isAuthenticated, + }); +} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 044567114..15c01cef7 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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(); diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx index 03df2c4dc..180f997fd 100644 --- a/src/components/medications/__tests__/side-effects-section.test.tsx +++ b/src/components/medications/__tests__/side-effects-section.test.tsx @@ -444,6 +444,7 @@ describe(" — timeline rows", () => { describe(" — paging", () => { function sideEffectsResponse( items: Array<{ id: string; occurredAt: string }>, + truncated = false, ): Response { return new Response( JSON.stringify({ @@ -458,12 +459,32 @@ describe(" — 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(, client); + await executeSideEffectsQuery(client, "med-full"); + await waitForQueryStatus(client, "med-full", "success"); + const html = render(, 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(" — 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([ diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 114c1b23b..b28beff76 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -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, diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index b2c0565e3..c001dce23 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -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( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index c47885781..3919ed1e6 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -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)} /> )} + )} diff --git a/src/components/records/__tests__/allergy-manager.test.tsx b/src/components/records/__tests__/allergy-manager.test.tsx index dd2b60a9b..01e7c2c2a 100644 --- a/src/components/records/__tests__/allergy-manager.test.tsx +++ b/src/components/records/__tests__/allergy-manager.test.tsx @@ -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", () => ({ diff --git a/src/components/records/__tests__/family-history-manager.test.tsx b/src/components/records/__tests__/family-history-manager.test.tsx index 1064b5607..8158c95ec 100644 --- a/src/components/records/__tests__/family-history-manager.test.tsx +++ b/src/components/records/__tests__/family-history-manager.test.tsx @@ -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", () => ({ diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index 901c79731..c6a1ed919 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -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(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, error, refetch } = useQuery({ + const { + data: page, + isLoading, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.allergyList(true), - queryFn: () => apiGet("/api/allergies"), + queryFn: () => apiGetList("/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({ } /> ) : ( -
    {rows.map(renderRow)}
+ <> +
    {rows.map(renderRow)}
+ + )} {/* The add action closes the list (design standards §12); an empty diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index ac2db6312..2f4aa0c08 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -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(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, error, refetch } = useQuery({ + const { + data: page, + isLoading, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.familyHistoryList(), - queryFn: () => apiGet("/api/family-history"), + queryFn: () => apiGetList("/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() { } /> ) : ( -
    {rows.map(renderRow)}
+ <> +
    {rows.map(renderRow)}
+ + )} {/* The add action closes the list as the card's action row (design diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 288a064d4..3104f1fe5 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -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(" — 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( diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx index fe87aa436..aa6262296 100644 --- a/src/components/settings/access/__tests__/access-panel.test.tsx +++ b/src/components/settings/access/__tests__/access-panel.test.tsx @@ -32,7 +32,9 @@ function grantList(partial: Partial = {}): 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(); 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(); - 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(); - 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(); 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()).toContain( + 'data-slot="list-cap-notice"', + ); + grantsRef.value = grantList({ given: [BASE] }); + expect(render()).not.toContain( + 'data-slot="list-cap-notice"', + ); + }); +}); diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index f7e6f5e49..71dc4d500 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -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() { ))} )} + ); diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index 9d75f9ba3..470e7a87c 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -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() { ))} )} + ); diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx index 06fe54112..ae19c1b57 100644 --- a/src/components/settings/access/record-activity-card.tsx +++ b/src/components/settings/access/record-activity-card.tsx @@ -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 && ( - <> - {" "} - - {t("recordSharing.activity.truncated")} - - - )}

)} + {/* 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. */} + {isLoading && ( - 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 ( @@ -152,6 +159,7 @@ export function SecurityActivityCard({ ))} )} + ); diff --git a/src/components/ui/__tests__/list-cap-notice.test.tsx b/src/components/ui/__tests__/list-cap-notice.test.tsx new file mode 100644 index 000000000..6fb300c0d --- /dev/null +++ b/src/components/ui/__tests__/list-cap-notice.test.tsx @@ -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({node}); + +describe("ListCapNotice", () => { + it("says the list stopped at its ceiling, with the number", () => { + const html = render(); + 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(), + ).toBe(""); + expect(render()).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(); + }); +}); diff --git a/src/components/ui/list-cap-notice.tsx b/src/components/ui/list-cap-notice.tsx new file mode 100644 index 000000000..c1fe6160b --- /dev/null +++ b/src/components/ui/list-cap-notice.tsx @@ -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 ( +

+ {t("common.listCapped", { count: String(cap.limit) })} +

+ ); +} diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 732424e68..50faedcdf 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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, diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index f489baf44..2d43ab91f 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -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 / diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index c137b62d3..4e3d18327 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -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" = diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 95c13f927..ffabbc4a8 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -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( + path: string, + init?: RequestInit, +): Promise<{ data: T; cap: ListCap | null }> { + const { data, meta } = await apiFetchEnvelope(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( + path: string, + body?: unknown, + init?: RequestInit, +): Promise<{ data: T; cap: ListCap | null }> { + const { data, meta } = await apiFetchEnvelope( + path, + withDefaultIdempotencyKey(withJsonBody("POST", body, init)), + ); + return { data, cap: readListCap(meta) }; +} + /** GET — resolves with the unwrapped `data` payload. */ export function apiGet( path: string, diff --git a/src/lib/api/list-cap-shape.ts b/src/lib/api/list-cap-shape.ts new file mode 100644 index 000000000..f6004cf2d --- /dev/null +++ b/src/lib/api/list-cap-shape.ts @@ -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; + if (typeof truncated !== "boolean") return null; + if (typeof limit !== "number" || !Number.isFinite(limit)) return null; + return { truncated, limit }; +} diff --git a/src/lib/api/list-cap.ts b/src/lib/api/list-cap.ts index e2bc9ef4f..98cd2436c 100644 --- a/src/lib/api/list-cap.ts +++ b/src/lib/api/list-cap.ts @@ -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 { diff --git a/src/lib/crypto/__tests__/sealed-or-legacy.test.ts b/src/lib/crypto/__tests__/sealed-or-legacy.test.ts new file mode 100644 index 000000000..71e816a1e --- /dev/null +++ b/src/lib/crypto/__tests__/sealed-or-legacy.test.ts @@ -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"); + }); +}); diff --git a/src/lib/crypto/note-cipher.ts b/src/lib/crypto/note-cipher.ts index dc63001dd..04463d9e2 100644 --- a/src/lib/crypto/note-cipher.ts +++ b/src/lib/crypto/note-cipher.ts @@ -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", + }); } /** diff --git a/src/lib/crypto/sealed-or-legacy.ts b/src/lib/crypto/sealed-or-legacy.ts new file mode 100644 index 000000000..4de4cfb22 --- /dev/null +++ b/src/lib/crypto/sealed-or-legacy.ts @@ -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 = "throw" | ((error: unknown) => T | null); + +export function readSealedOrLegacy(args: { + sealed: Uint8Array | null | undefined; + legacy: T | null | undefined; + open: (sealed: Uint8Array) => T; + onUndecryptable: OnUndecryptable; +}): 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; + }; +} diff --git a/src/lib/daily/briefing-today-read.ts b/src/lib/daily/briefing-today-read.ts index f9d74bb20..49ace8d14 100644 --- a/src/lib/daily/briefing-today-read.ts +++ b/src/lib/daily/briefing-today-read.ts @@ -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; diff --git a/src/lib/export.ts b/src/lib/export.ts index f2ad30b28..80718d53a 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -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: "", }, diff --git a/src/lib/import/health-connect/import.ts b/src/lib/import/health-connect/import.ts index a36b8e20f..a35f8d481 100644 --- a/src/lib/import/health-connect/import.ts +++ b/src/lib/import/health-connect/import.ts @@ -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 { 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 { - 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(); } diff --git a/src/lib/insights/__tests__/cached-text-cipher.test.ts b/src/lib/insights/__tests__/cached-text-cipher.test.ts index 276347f58..8fcd71142 100644 --- a/src/lib/insights/__tests__/cached-text-cipher.test.ts +++ b/src/lib/insights/__tests__/cached-text-cipher.test.ts @@ -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", () => { diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 0f582b50c..82480c191 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index 1c3c74ada..bd44c3aec 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -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(); + 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(), })); diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts index 4254c33be..5302d72e2 100644 --- a/src/lib/insights/__tests__/locale-pipeline.test.ts +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -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(); + 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(), })); diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts index c26ee7d95..5e7dcf05b 100644 --- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -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/, ); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 6965abddc..e593ba9b8 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -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); diff --git a/src/lib/insights/__tests__/recent-readings.test.ts b/src/lib/insights/__tests__/recent-readings.test.ts new file mode 100644 index 000000000..4cb16aa6d --- /dev/null +++ b/src/lib/insights/__tests__/recent-readings.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/__tests__/symptom-series-chronic.test.ts b/src/lib/insights/__tests__/symptom-series-chronic.test.ts index c949b3380..a6a31e0d1 100644 --- a/src/lib/insights/__tests__/symptom-series-chronic.test.ts +++ b/src/lib/insights/__tests__/symptom-series-chronic.test.ts @@ -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 }, + ]); + }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 463073a7e..2063bbed6 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -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) => ({ diff --git a/src/lib/insights/cached-text-cipher.ts b/src/lib/insights/cached-text-cipher.ts index d51cb65a7..c74cbfcb0 100644 --- a/src/lib/insights/cached-text-cipher.ts +++ b/src/lib/insights/cached-text-cipher.ts @@ -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"), + }); } diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 3e038c63c..47ea68dbb 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -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)); } diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index 7da33628e..a34095560 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -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)); diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts index 259d3c952..00b60b45e 100644 --- a/src/lib/insights/derived/trajectory.ts +++ b/src/lib/insights/derived/trajectory.ts @@ -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; diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index a13f3b384..4c52bcd09 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -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 }, }); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 7e5230b54..1bb525a4e 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -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, diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index 57caf5be3..4f82da485 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -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, 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; } /** diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 3d6168610..69c1aff79 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 7cc878483..e19388e09 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -241,6 +241,7 @@ export async function prepareMoodStatusForUser( .findMany({ where: { userId, + deletedAt: null, }, orderBy: { date: "desc" }, take: 90, diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 9262f3fba..3f7dfee82 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -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, diff --git a/src/lib/insights/recent-readings.ts b/src/lib/insights/recent-readings.ts new file mode 100644 index 000000000..3d210cf98 --- /dev/null +++ b/src/lib/insights/recent-readings.ts @@ -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 { + 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> { + 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), + })); +} diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 053ba36e4..c5ec126e2 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -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) => ({ diff --git a/src/lib/integrations/__tests__/import-value.test.ts b/src/lib/integrations/__tests__/import-value.test.ts index ff83ad1bf..73aa3faf1 100644 --- a/src/lib/integrations/__tests__/import-value.test.ts +++ b/src/lib/integrations/__tests__/import-value.test.ts @@ -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); + }); }); diff --git a/src/lib/integrations/import-value.ts b/src/lib/integrations/import-value.ts index a3de3d846..616b8126c 100644 --- a/src/lib/integrations/import-value.ts +++ b/src/lib/integrations/import-value.ts @@ -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; } diff --git a/src/lib/mcp/__tests__/record-search.test.ts b/src/lib/mcp/__tests__/record-search.test.ts index 4d05457da..7a7f5e9e1 100644 --- a/src/lib/mcp/__tests__/record-search.test.ts +++ b/src/lib/mcp/__tests__/record-search.test.ts @@ -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()), + readLatestEver: vi.fn(async () => []), +})); vi.mock("@/lib/links", () => ({ listTargets: vi.fn( async ( diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index d6de27350..63f09c7c3 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -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()), + 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"; diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index f316ccfaa..f736e0d92 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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()), + 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:", 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 () => { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index d5b046a11..ee6e575f0 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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; diff --git a/src/lib/measurements/list/sleep-list.ts b/src/lib/measurements/list/sleep-list.ts index 32b205de8..288849d35 100644 --- a/src/lib/measurements/list/sleep-list.ts +++ b/src/lib/measurements/list/sleep-list.ts @@ -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:` 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(), diff --git a/src/lib/measurements/list/sleep-nights.ts b/src/lib/measurements/list/sleep-nights.ts index ee6f3608a..156f9c308 100644 --- a/src/lib/measurements/list/sleep-nights.ts +++ b/src/lib/measurements/list/sleep-nights.ts @@ -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 { - 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( diff --git a/src/lib/mood/__tests__/recent-scores.test.ts b/src/lib/mood/__tests__/recent-scores.test.ts new file mode 100644 index 000000000..9cd2080a3 --- /dev/null +++ b/src/lib/mood/__tests__/recent-scores.test.ts @@ -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]); + }); +}); diff --git a/src/lib/mood/recent-scores.ts b/src/lib/mood/recent-scores.ts new file mode 100644 index 000000000..13c757506 --- /dev/null +++ b/src/lib/mood/recent-scores.ts @@ -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 { + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + take, + select: { date: true, score: true, moodLoggedAt: true }, + }); + return rows.reverse(); +} diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index c12a73a2d..54a0e12fc 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -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 = { 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": { diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index a2e25ad6d..e93227d1f 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -328,6 +328,27 @@ export const measurementResource = z // Synthesised-row extras — present only on the collapsed day-sum mode. dayKey: z.string().optional(), sampleCount: z.number().int().optional(), + // Per-night extras — present only on `type=SLEEP_DURATION` list rows. + mainAsleepMinutes: z + .number() + .int() + .nonnegative() + .nullable() + .optional() + .describe( + "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.", + ), + napCount: z + .number() + .int() + .nonnegative() + .optional() + .describe("Naps on the same wake day."), + napAsleepMinutes: z + .number() + .nonnegative() + .optional() + .describe("Time asleep across those naps, in minutes."), }) .meta({ id: "MeasurementResource", @@ -804,7 +825,7 @@ export const measurementPaths: NonNullable = { summary: "List measurements", description: "Filter by type + date range. Response is paged via offset/limit; default limit 100.\n\n" + - "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.", + "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 `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.", requestParams: { query: listMeasurementsSchema, }, @@ -1118,7 +1139,7 @@ export const measurementPaths: NonNullable = { tags: ["Measurements"], summary: "Reconstructed sleep night (hypnogram source, v1.11.5)", description: - "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical 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.", + "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical 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 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.", requestParams: { query: z.object({ date: z @@ -1126,7 +1147,7 @@ export const measurementPaths: NonNullable = { .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe( - "Wake-day key (YYYY-MM-DD). Omit for the most recent night.", + "Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.", ), }), }, diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts index f2db0e1b9..549b7aade 100644 --- a/src/lib/queries/use-account-grants.ts +++ b/src/lib/queries/use-account-grants.ts @@ -24,9 +24,10 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch"; import { grantDependentKeys, queryKeys } from "@/lib/query-keys"; import type { ShareDomain } from "@/lib/sharing/scope"; +import type { ListCap } from "@/lib/api/list-cap-shape"; /** The other party to a grant, as far as anyone is told about them. */ export interface GrantParty { @@ -93,21 +94,26 @@ export interface RecordActivity { * the list is everything that ever happened. */ retentionDays: number; - /** - * True when the read hit its row ceiling, so the list is the most recent - * activity inside the window rather than all of it. - * - * Two different completeness claims, and both have to be stated: the window - * bounds how far back, this bounds how many. A list that silently stopped at - * its own cap would let the oldest line read as the beginning. - */ - truncated: boolean; } +/** A capped list as its hook hands it out: the payload and the route's cap. */ +type WithCap = T & { + /** + * The route's list cap (`meta` on the envelope), null when it sent none. + * Shown with `ListCapNotice`: the retention window bounds how far back the + * feed sees, the cap how many rows came back, and a list that stopped at + * its ceiling in silence would let its oldest line read as the beginning. + */ + cap: ListCap | null; +}; + export function useAccountGrants(enabled = true) { return useQuery({ queryKey: queryKeys.accountGrants(), - queryFn: () => apiGet("/api/account/grants"), + queryFn: async (): Promise> => { + const { data, cap } = await apiGetList("/api/account/grants"); + return { ...data, cap }; + }, enabled, }); } @@ -115,7 +121,12 @@ export function useAccountGrants(enabled = true) { export function useRecordActivity(enabled = true) { return useQuery({ queryKey: queryKeys.accountActivity(), - queryFn: () => apiGet("/api/account/activity"), + queryFn: async (): Promise> => { + const { data, cap } = await apiGetList( + "/api/account/activity", + ); + return { ...data, cap }; + }, enabled, }); } diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index 82b7c6070..ede087a84 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -38,6 +38,7 @@ import { getSourceLadder, parseSourcePriority, } from "@/lib/validations/source-priority"; +import { fitFromSums } from "@/lib/stats/linear-fit"; /** * v1.11.1 — load a user's source-priority blob for the rollup collapse. @@ -217,18 +218,12 @@ export function composeRegression( if (n < 2) return MISS; - // Mean-centered (corrected-sum) identities. Sxx/Sxy/Syy are the determinant - // terms divided by n — algebraically identical to `n·Σxx − Σx²` etc., but - // they subtract `Σx²/n` from `Σxx` at matched scale, avoiding the - // catastrophic cancellation the determinant form suffers on the ~1e10 - // epoch-day x-axis. See the function header for the full rationale. - const sxx = sumXx - (sumX * sumX) / n; + // The centred fit over the accumulators (`fitFromSums`), the same + // arithmetic every point fit uses. No x variance: no slope, no r². + const fit = fitFromSums({ n, sumX, sumY, sumXx, sumXy, sumYy }); + const slope = fit ? fit.slope : null; + const r2 = fit ? fit.r2 : null; const syy = sumYy - (sumY * sumY) / n; - const sxy = sumXy - (sumX * sumY) / n; - - const slope = sxx === 0 ? null : sxy / sxx; - // REGR_R2 is null when either variance is zero (matches Postgres). - const r2 = sxx === 0 || syy === 0 ? null : (sxy * sxy) / (sxx * syy); // Population variance: Syy/n = Σyy/n − (Σy/n)². Clamp tiny negative values // float rounding can produce when the variance is effectively zero. diff --git a/src/lib/stats/__tests__/linear-fit.test.ts b/src/lib/stats/__tests__/linear-fit.test.ts new file mode 100644 index 000000000..f9702977e --- /dev/null +++ b/src/lib/stats/__tests__/linear-fit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { fitFromSums, linearFit, sumsOf, trendLineByTime } from "../linear-fit"; + +describe("linearFit", () => { + it("fits an exact line", () => { + const fit = linearFit([0, 1, 2, 3], [1, 3, 5, 7])!; + expect(fit.slope).toBeCloseTo(2, 12); + expect(fit.intercept).toBeCloseTo(1, 12); + expect(fit.r2).toBeCloseTo(1, 12); + }); + + it("answers null without a slope, and a null r² without y variance", () => { + expect(linearFit([1], [1])).toBeNull(); + expect(linearFit([2, 2, 2], [1, 2, 3])).toBeNull(); + expect(linearFit([0, 1, 2], [5, 5, 5])!.r2).toBeNull(); + }); + + it("keeps its digits on an epoch-day axis", () => { + const xs = [20_000, 20_001, 20_002, 20_003].map((d) => d * 86_400); + const fit = linearFit(xs, [10, 11, 12, 13])!; + expect(fit.slope * 86_400).toBeCloseTo(1, 6); + }); + + it("fits the same from points and from their sums", () => { + const xs = [0, 2, 3, 7, 9]; + const ys = [4, 5, 5, 9, 11]; + expect(fitFromSums(sumsOf(xs, ys))).toEqual(linearFit(xs, ys)); + }); +}); + +describe("trendLineByTime", () => { + it("draws the fitted value at two decimals per point, in time order", () => { + const day = (n: number) => new Date(Date.UTC(2026, 0, 1 + n)); + const line = trendLineByTime([ + { date: day(2), value: 3 }, + { date: day(0), value: 1 }, + { date: day(1), value: 2.5 }, + ])!; + expect(line.points.map((p) => p.date)).toEqual([day(0), day(1), day(2)]); + expect(line.slopePerDay).toBeCloseTo(1, 12); + expect(line.points.map((p) => p.value)).toEqual([1.17, 2.17, 3.17]); + }); +}); diff --git a/src/lib/stats/linear-fit.ts b/src/lib/stats/linear-fit.ts new file mode 100644 index 000000000..2b05f8592 --- /dev/null +++ b/src/lib/stats/linear-fit.ts @@ -0,0 +1,129 @@ +/** + * The least-squares line, in one place. + * + * Six readers fitted a line by hand: two charts, the trend summary, the + * graded series, the trajectory fit and the rollup regression composed from + * stored accumulators. Two of them used the centred sums, four the + * determinant form `n·Σxy − Σx·Σy`, which loses digits on a large x axis + * (epoch days) that the centred form keeps. Every fit now goes through + * {@link fitFromSums}, the centred form over the accumulators, so a fit read + * from points and one composed from stored sums are the same arithmetic. + * + * `src/__tests__/linear-fit-single-implementation-guard.test.ts` keeps the + * hand-rolled forms out of the rest of the tree. + */ + +/** The sums a fit is composed from. */ +export interface FitSums { + n: number; + sumX: number; + sumY: number; + sumXx: number; + sumXy: number; + sumYy: number; +} + +export interface LinearFit { + slope: number; + intercept: number; + /** Null when y has no variance (Postgres REGR_R2 answers null too). */ + r2: number | null; + n: number; + meanX: number; + meanY: number; + /** Centred sums of squares and products. */ + sxx: number; + sxy: number; + syy: number; +} + +/** + * Fit from accumulated sums. Null below two points or when every x is the + * same (no slope). The centred identities `Sxx = Σxx − (Σx)²/n` subtract at + * matched scale, so the cancellation is bounded by the x variance, not the + * x magnitude. + */ +export function fitFromSums(sums: FitSums): LinearFit | null { + const { n, sumX, sumY, sumXx, sumXy, sumYy } = sums; + if (n < 2) return null; + const sxx = sumXx - (sumX * sumX) / n; + const sxy = sumXy - (sumX * sumY) / n; + const syy = sumYy - (sumY * sumY) / n; + if (sxx === 0) return null; + const slope = sxy / sxx; + const meanX = sumX / n; + const meanY = sumY / n; + return { + slope, + intercept: meanY - slope * meanX, + r2: syy === 0 ? null : (sxy * sxy) / (sxx * syy), + n, + meanX, + meanY, + sxx, + sxy, + syy, + }; +} + +/** The sums of paired values. */ +export function sumsOf(xs: readonly number[], ys: readonly number[]): FitSums { + if (xs.length !== ys.length) { + throw new RangeError("linear fit: xs and ys differ in length"); + } + let sumX = 0; + let sumY = 0; + let sumXx = 0; + let sumXy = 0; + let sumYy = 0; + for (let i = 0; i < xs.length; i += 1) { + const x = xs[i]; + const y = ys[i]; + sumX += x; + sumY += y; + sumXx += x * x; + sumXy += x * y; + sumYy += y * y; + } + return { n: xs.length, sumX, sumY, sumXx, sumXy, sumYy }; +} + +/** Fit `ys` against `xs`. */ +export function linearFit( + xs: readonly number[], + ys: readonly number[], +): LinearFit | null { + return fitFromSums(sumsOf(xs, ys)); +} + +/** One day in milliseconds, the x unit of a trend over dated points. */ +const DAY_MS = 86_400_000; + +/** + * The fitted line over dated points, x in days from the first point: the + * chart trend lines draw it, with each fitted value at two decimals. Null + * below two points or when every point shares one instant. + */ +export function trendLineByTime( + data: ReadonlyArray<{ 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 xOf = (d: Date) => (d.getTime() - startTime) / DAY_MS; + const fit = linearFit( + sorted.map((p) => xOf(p.date)), + sorted.map((p) => p.value), + ); + if (!fit) return null; + return { + points: sorted.map((p) => ({ + date: p.date, + value: Math.round((fit.intercept + fit.slope * xOf(p.date)) * 100) / 100, + })), + slopePerDay: fit.slope, + }; +} diff --git a/src/lib/workouts/route-geometry-cipher.ts b/src/lib/workouts/route-geometry-cipher.ts index 80ed5f4fb..5387a663c 100644 --- a/src/lib/workouts/route-geometry-cipher.ts +++ b/src/lib/workouts/route-geometry-cipher.ts @@ -23,7 +23,7 @@ import { Buffer } from "node:buffer"; import { decryptBytes, encryptBytes } from "@/lib/crypto"; import { WORKOUT_ROUTE_GEOMETRY_AAD } from "@/lib/crypto/encrypted-columns"; -import { getEvent } from "@/lib/logging/context"; +import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy"; /** Seal a route's GeoJSON geometry for `WorkoutRoute.geometryEncrypted`. */ export function encryptRouteGeometry( @@ -53,17 +53,10 @@ export function readRouteGeometry(row: { geometry?: unknown; geometryEncrypted?: Uint8Array | null; }): unknown { - if (row.geometryEncrypted && row.geometryEncrypted.byteLength > 0) { - try { - return decryptRouteGeometry(row.geometryEncrypted); - } catch (err) { - getEvent()?.addWarning( - `workout route decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return null; - } - } - return row.geometry ?? null; + return readSealedOrLegacy({ + sealed: row.geometryEncrypted, + legacy: row.geometry, + open: decryptRouteGeometry, + onUndecryptable: warnAndDrop("workout route decrypt failed"), + }); } diff --git a/tests/integration/analytics-summaries-slice.test.ts b/tests/integration/analytics-summaries-slice.test.ts index 244c2f98a..7ff10e366 100644 --- a/tests/integration/analytics-summaries-slice.test.ts +++ b/tests/integration/analytics-summaries-slice.test.ts @@ -185,6 +185,37 @@ describe("GET /api/analytics?slice=summaries (C1)", () => { expect(envelope.data.summaries.PULSE.count).toBe(0); }); + it("summarises sleep over the whole history, not the last year", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("slim-sleep-history"); + const DAY_MS = 24 * 60 * 60 * 1000; + const morning = new Date(); + morning.setUTCHours(6, 0, 0, 0); + // One 7 h night every third day for two years. + const nights = Array.from({ length: 244 }, (_, i) => ({ + userId: user.id, + type: "SLEEP_DURATION" as const, + value: 420, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(morning.getTime() - i * 3 * DAY_MS), + externalId: `uuid-slice-night-${i}`, + sleepStage: "CORE" as const, + })); + await prisma.measurement.createMany({ data: nights }); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics?slice=summaries")); + expect(response.status).toBe(200); + const sleep = ((await response.json()) as SlimEnvelope).data.summaries + .SLEEP_DURATION; + // Every night counts, and the window a year back has nights in it. + expect(sleep.count).toBe(244); + expect(sleep.avg30LastYear).toBe(420); + }); + it("default slice still carries the thick envelope (additive contract)", async () => { await seedSession("slim-default-still-thick"); diff --git a/tests/integration/health-connect-import.test.ts b/tests/integration/health-connect-import.test.ts index 87cd33fd9..e7dceb5bd 100644 --- a/tests/integration/health-connect-import.test.ts +++ b/tests/integration/health-connect-import.test.ts @@ -20,6 +20,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -233,6 +234,29 @@ describe("Health Connect import, mapping", () => { expect(JSON.stringify(result)).not.toContain("Evening run"); }); + it("stores a scaled value with the import rounding, not its unit factor's noise", async () => { + const prisma = getPrismaClient(); + const user = await createUser("hc-round"); + const { path } = fixtureDb("round", { days: 1, endUtc: today }); + // Grams to kilograms: 80 345.678 g is 80.345678 kg before rounding. + const db = new DatabaseSync(path); + db.exec("UPDATE weight_record_table SET weight = 80345.678"); + db.close(); + await importHealthConnectExport({ + prisma, + dbPath: path, + userId: user.id, + userTimezone: "Europe/Berlin", + connectedIntegrations: [], + }); + const weights = await prisma.measurement.findMany({ + where: { userId: user.id, type: "WEIGHT", source: "HEALTH_CONNECT" }, + select: { value: true }, + }); + expect(weights.length).toBeGreaterThan(0); + for (const w of weights) expect(w.value).toBe(80.35); + }); + it("writes nothing the second time the same export comes in", async () => { const prisma = getPrismaClient(); const user = await createUser("hc-twice"); diff --git a/tests/integration/insights-recent-readings.test.ts b/tests/integration/insights-recent-readings.test.ts new file mode 100644 index 000000000..3fa334a9d --- /dev/null +++ b/tests/integration/insights-recent-readings.test.ts @@ -0,0 +1,64 @@ +/** + * The overview's per-type latest-readings query against Postgres: a dense + * stream is bounded on its own and cannot crowd a sparse type out, deleted + * rows stay out, and the rows come back oldest first. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readLatestReadingsPerType } from "@/lib/insights/recent-readings"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("readLatestReadingsPerType", () => { + it("bounds each type on its own", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "recent-per-type", email: "rpt@example.test" }, + }); + const now = Date.now(); + const base = { + userId: user.id, + source: "APPLE_HEALTH" as const, + }; + await prisma.measurement.createMany({ + data: [ + // 6 000 pulse readings a minute apart: a few days of a watch. + ...Array.from({ length: 6000 }, (_, i) => ({ + ...base, + type: "PULSE" as const, + value: 60 + (i % 20), + unit: "bpm", + measuredAt: new Date(now - i * 60_000), + })), + // Weight once a week for a year, the newest one deleted. + ...Array.from({ length: 52 }, (_, i) => ({ + ...base, + source: "MANUAL" as const, + type: "WEIGHT" as const, + value: 80, + unit: "kg", + measuredAt: new Date(now - i * 7 * 86_400_000), + deletedAt: i === 0 ? new Date() : null, + })), + ], + }); + + const rows = await readLatestReadingsPerType( + user.id, + ["PULSE", "WEIGHT", "BODY_FAT"], + 365, + ); + const byType = (t: string) => rows.filter((r) => r.type === t); + expect(byType("PULSE")).toHaveLength(365); + expect(byType("WEIGHT")).toHaveLength(51); + expect(byType("BODY_FAT")).toHaveLength(0); + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i].measuredAt >= rows[i - 1].measuredAt).toBe(true); + } + // The newest pulse reading is in, the oldest of the 6 000 is not. + expect(byType("PULSE").at(-1)!.measuredAt.getTime()).toBe(now); + }); +}); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 5db0eaf56..673347319 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -54,6 +54,7 @@ interface ListEnvelope { sampleCount?: number; sleepStage?: string | null; napCount?: number; + mainAsleepMinutes?: number | null; awakenings?: number; }>; meta: { total: number; groupBy?: string; windowFrom?: string }; @@ -231,7 +232,9 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { expect(firstBody.data.meta.windowFrom).toBeUndefined(); expect(firstBody.data.measurements).toHaveLength(25); expect(firstBody.data.measurements[0].dayKey).toBe(dayKeyOf(morning)); - expect(firstBody.data.measurements[0].value).toBe(420); + // The day's sleep counts the nap; the main night rides beside it. + expect(firstBody.data.measurements[0].value).toBe(450); + expect(firstBody.data.measurements[0].mainAsleepMinutes).toBe(420); expect(firstBody.data.measurements[0].napCount).toBe(1); // The stage read covered the page's nights, not the history. const read = spy.mock.calls @@ -256,7 +259,8 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { expect(body.data.meta.total).toBe(NIGHTS); for (const row of body.data.measurements) { seen.push(row.dayKey!); - expect(row.value).toBe(420); + expect(row.mainAsleepMinutes).toBe(420); + expect(row.value).toBe(row.napCount ? 450 : 420); } } expect(seen).toHaveLength(NIGHTS); diff --git a/tests/integration/server-cache-routes.test.ts b/tests/integration/server-cache-routes.test.ts index 41456c4b2..6e5535fac 100644 --- a/tests/integration/server-cache-routes.test.ts +++ b/tests/integration/server-cache-routes.test.ts @@ -85,12 +85,56 @@ async function seedSession(username: string) { return user; } +/** + * Count the builder runs per key on a cache. The analytics bucket holds two + * cells per account: the thick default body (`|default|`) and + * the summaries slice it composes (`|summaries`), which the tile-strip + * route and the snapshot read too. The bucket-wide `misses` counter therefore + * reads 2 on a cold mount; coalescing is proven per key, one build each. + */ +function countBuildsPerKey(cache: { + wrap: (...args: never[]) => Promise; +}): Map { + const builds = new Map(); + const original = cache.wrap.bind(cache) as unknown as ( + key: string, + builder: () => Promise, + ttl?: number, + ) => Promise; + vi.spyOn(cache, "wrap").mockImplementation((( + key: string, + builder: () => Promise, + ttl?: number, + ) => + original( + key, + () => { + builds.set(key, (builds.get(key) ?? 0) + 1); + return builder(); + }, + ttl, + )) as never); + return builds; +} + +function buildsOf(builds: Map, userId: string) { + const entries = [...builds.entries()].filter(([k]) => k.startsWith(userId)); + return { + defaultBody: entries + .filter(([k]) => k.startsWith(`${userId}|default|`)) + .reduce((n, [, c]) => n + c, 0), + summaries: builds.get(`${userId}|summaries`) ?? 0, + keys: entries.length, + }; +} + describe("v1.4.34 IW-G — server-cache route integration", () => { it("coalesces 10 concurrent /api/analytics reads into a single builder call", async () => { - await seedSession("cache-stampede-user"); + const user = await seedSession("cache-stampede-user"); const { GET } = await import("@/app/api/analytics/route"); const { caches } = await import("@/lib/cache/server-cache"); + const builds = countBuildsPerKey(caches.analytics); // Fire 10 concurrent reads against a cold cache. The first sets up // the `pending` promise; the other nine join it. The cache's @@ -110,8 +154,15 @@ describe("v1.4.34 IW-G — server-cache route integration", () => { // microtasks — the now-warm cache (a hit). What we forbid is a // second miss: that would mean the single-flight join failed and a // duplicate builder fired. + // Per cell: the default body built once and the summaries slice it + // composes built once, nothing else. The bucket counter sums both cells. + expect(buildsOf(builds, user.id)).toEqual({ + defaultBody: 1, + summaries: 1, + keys: 2, + }); const stats = caches.analytics.stats(); - expect(stats.misses).toBe(1); + expect(stats.misses).toBe(2); expect(stats.stampedes + stats.hits).toBe(9); // Subsequent serial reads inside the TTL hit the warm cache. const warm = await (GET as unknown as (req: Request) => Promise)( @@ -129,13 +180,19 @@ describe("v1.4.34 IW-G — server-cache route integration", () => { const { POST: postMeasurement } = await import("@/app/api/measurements/route"); const { caches } = await import("@/lib/cache/server-cache"); + const builds = countBuildsPerKey(caches.analytics); - // Prime the cache — first read is a miss; second is a hit. + // Prime the cache — first read builds both cells once; second is a hit. const first = await (GET as unknown as (req: Request) => Promise)( new Request("http://localhost/api/analytics"), ); expect(first.status).toBe(200); - expect(caches.analytics.stats().misses).toBe(1); + expect(buildsOf(builds, user.id)).toEqual({ + defaultBody: 1, + summaries: 1, + keys: 2, + }); + expect(caches.analytics.stats().misses).toBe(2); expect(caches.analytics.stats().hits).toBe(0); const second = await ( @@ -167,7 +224,13 @@ describe("v1.4.34 IW-G — server-cache route integration", () => { new Request("http://localhost/api/analytics"), ); expect(third.status).toBe(200); - expect(caches.analytics.stats().misses).toBe(2); + // Both cells were evicted by the write and rebuilt once each. + expect(buildsOf(builds, user.id)).toEqual({ + defaultBody: 2, + summaries: 2, + keys: 2, + }); + expect(caches.analytics.stats().misses).toBe(4); // Sanity: the body reflects the new write. const body = (await third.json()) as { diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 426b86d9c..f29121eb2 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -462,8 +462,8 @@ describe("the activity feed states its own limits", () => { data: { entries: { action: string }[]; retentionDays: number; - truncated: boolean; }; + meta: { truncated: boolean; limit: number }; }; } @@ -509,7 +509,9 @@ describe("the activity feed states its own limits", () => { } const small = await feed(); expect(small.data.entries).toHaveLength(3); - expect(small.data.truncated).toBe(false); + // The cap rides the envelope, like every capped list's. + expect(small.meta.truncated).toBe(false); + expect(small.data).not.toHaveProperty("truncated"); // Over it: same shape, different claim. await getPrismaClient().auditLog.createMany({ @@ -522,6 +524,6 @@ describe("the activity feed states its own limits", () => { }); const large = await feed(); expect(large.data.entries).toHaveLength(100); - expect(large.data.truncated).toBe(true); + expect(large.meta).toMatchObject({ truncated: true, limit: 100 }); }); }); diff --git a/tests/integration/sleep-night-sql-parity.test.ts b/tests/integration/sleep-night-sql-parity.test.ts index 129ec0697..b6a11b0d7 100644 --- a/tests/integration/sleep-night-sql-parity.test.ts +++ b/tests/integration/sleep-night-sql-parity.test.ts @@ -191,6 +191,7 @@ async function seed(timezone: string) { interface ListRow { dayKey: string; value: number; + mainAsleepMinutes: number | null; napCount: number; napAsleepMinutes: number; } @@ -245,9 +246,15 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { const jsNights = [...new Set(scorable.map((s) => s.night))].sort(); expect(page.nights.map((n) => n.night)).toEqual(jsNights); expect(page.total).toBe(jsNights.length); - // Every reconstructed session lies inside its night's SQL span. + // Every reconstructed session of a page night lies inside the night's + // SQL span, the in-bed-only ones included: the reconstruction adds + // their in-bed time to the night, so a chunked read must reach them. const span = new Map(page.nights.map((n) => [n.night, n])); - for (const s of scorable) { + const sessions = reconstructSleepSessions(rows, tz, null).filter((s) => + span.has(s.night), + ); + expect(sessions.length).toBeGreaterThan(scorable.length); + for (const s of sessions) { const n = span.get(s.night)!; expect(s.start.getTime()).toBeGreaterThanOrEqual( n.spanStart.getTime() - 1000, @@ -255,6 +262,34 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { expect(s.end.getTime()).toBeLessThanOrEqual(n.spanEnd.getTime() + 1000); } }, 240_000); + + it(`reads the same in-bed time a night at a time and in chunks (${tz})`, async () => { + const user = await seed(tz); + const { readSleepNights, readSleepRows } = + await import("@/lib/measurements/list/sleep-nights"); + const { reconstructSleepNights } = + await import("@/lib/analytics/sleep-night"); + const chunked = await readSleepNights(user.id, tz, null, { + maxNights: 10_000, + }); + const whole = new Map( + reconstructSleepNights( + await readSleepRows( + user.id, + new Date(BASE - 2 * 86_400_000), + new Date(BASE + (DAYS + 2) * 86_400_000), + ), + tz, + null, + ).map((n) => [n.night, n]), + ); + expect(chunked.length).toBeGreaterThan(DAYS - 2); + for (const night of chunked) { + const ref = whole.get(night.night)!; + expect(night.inBedMinutes).toBe(ref.inBedMinutes); + expect(night.asleepMinutes).toBe(ref.asleepMinutes); + } + }, 240_000); } it("gives every page night a row the night view answers for the same day", async () => { @@ -275,8 +310,20 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { for (const row of rows) { const view = await nightView(row.dayKey); expect(view.data.night).toBe(row.dayKey); - expect(view.data.main?.asleepMinutes ?? 0).toBe(row.value); + // The row's value is the day's sleep, main night plus naps; the main + // night alone rides beside it. + const napMinutes = view.data.naps.reduce( + (sum, n) => sum + n.asleepMinutes, + 0, + ); + expect(row.value).toBe( + Math.round((view.data.main?.asleepMinutes ?? 0) + napMinutes), + ); + expect(row.mainAsleepMinutes).toBe( + view.data.main ? Math.round(view.data.main.asleepMinutes) : null, + ); expect(view.data.naps).toHaveLength(row.napCount); + if (row.napCount > 0) expect(row.value).toBeGreaterThan(0); if (!view.data.main) { napOnly += 1; expect(row.napCount).toBeGreaterThan(0); @@ -286,6 +333,69 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { expect(napOnly).toBe(DAYS / 10); }, 480_000); + it("states the same day total in the list and the series", async () => { + await seed("Europe/Berlin"); + const { GET } = await import("@/app/api/measurements/series/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=sleep&days=3650", + ), + ); + expect(res.status).toBe(200); + const series = ( + (await res.json()) as { + data: { points: Array<{ id: string; value: number }> }; + } + ).data.points; + const rows = (await list("type=SLEEP_DURATION&limit=1000&sortDir=asc")).data + .measurements; + const byDay = new Map(series.map((p) => [p.id.slice("sleep:".length), p])); + let napDays = 0; + for (const row of rows) { + const point = byDay.get(row.dayKey); + expect(point, row.dayKey).toBeDefined(); + // Hours to two places against whole minutes: within a minute. + expect(Math.abs(point!.value * 60 - row.value)).toBeLessThanOrEqual(1); + if (row.napCount > 0) napDays += 1; + } + expect(napDays).toBeGreaterThan(0); + }, 240_000); + + it("answers the latest main night, not a later day of naps alone", async () => { + const user = await seed("UTC"); + const prisma = getPrismaClient(); + // Two afternoon naps on the two days after the last fixture day. + const after = BASE + DAYS * 86_400_000; + await prisma.measurement.createMany({ + data: [0, 1].map((d) => ({ + userId: user.id, + type: "SLEEP_DURATION" as const, + value: 40, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(after + d * 86_400_000 + 14 * 3_600_000), + externalId: `late-nap-${d}`, + sleepStage: "CORE" as const, + })), + }); + const { GET } = await import("@/app/api/sleep/night/route"); + const res = await GET(new NextRequest("http://localhost/api/sleep/night")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { night: string; main: unknown; naps: unknown[] }; + }; + expect(body.data.main).not.toBeNull(); + // The answer is a fixture night, before the naps. + expect(body.data.night < new Date(after).toISOString().slice(0, 10)).toBe( + true, + ); + // The nap days stay reachable by date. + const napDay = new Date(after + 86_400_000).toISOString().slice(0, 10); + const nap = await nightView(napDay); + expect(nap.data.main).toBeNull(); + expect(nap.data.naps).toHaveLength(1); + }, 240_000); + it("reaches the oldest night from the series' All range", async () => { await seed("Europe/Berlin"); const { GET } = await import("@/app/api/measurements/series/route");