From 546b9b2569c1173e44da49504a01ad5350697adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 9 Nov 2026 10:59:18 +0100 Subject: [PATCH 01/46] feat(cycle): choose how the calendar feed draws a forecast (#1350) A forecast period was one all-day event over every day it might fall on, so a period of a few days showed up as a week-long estimate next to the logged ones. Each feed now carries a forecast style: - EXACT: the calculated period only, from the most likely start for the predicted number of bleeding days. - MARGIN: that event, plus separate "possible" events for the days it may start earlier and the days it may still run on, each only where the band reaches that side. Discreet titles use an open dot for them. - BAND: the single range, unchanged. Migration 0495 adds the column with BAND as its default, so every live feed keeps the events it serves. A feed turned on from now on starts on MARGIN, set by the mint route. PATCH changes it, rotate copies it, the status and the audit diff carry it, and the settings card offers it with a one-line explanation of the chosen style. The horizon and its stop rule are the same in every style, and the period event keeps its UID across styles so a calendar updates it in place. --- CHANGELOG.md | 16 ++ docs/api/openapi.yaml | 26 +++- messages/de.json | 12 ++ messages/en.json | 12 ++ messages/es.json | 12 ++ messages/fr.json | 12 ++ messages/it.json | 12 ++ messages/ko.json | 12 ++ messages/nl.json | 12 ++ messages/pl.json | 12 ++ messages/pt-BR.json | 12 ++ messages/ru.json | 12 ++ .../migration.sql | 33 ++++ prisma/schema.prisma | 22 ++- .../cycle-feed-content-guard.test.ts | 14 +- .../cycle/feed-subscription/rotate/route.ts | 4 + src/app/api/cycle/feed-subscription/route.ts | 11 +- .../cycle-calendar-feed-card.test.tsx | 25 +++ .../cycle/cycle-calendar-feed-card.tsx | 27 ++++ src/components/cycle/use-cycle-feed.ts | 8 +- src/lib/cycle/feed/__tests__/events.test.ts | 145 ++++++++++++++++++ src/lib/cycle/feed/__tests__/fixtures.ts | 1 + src/lib/cycle/feed/__tests__/serve.test.ts | 1 + src/lib/cycle/feed/build.ts | 8 +- src/lib/cycle/feed/events.ts | 104 ++++++++++--- src/lib/cycle/feed/serve.ts | 1 + src/lib/cycle/feed/status.ts | 6 +- src/lib/openapi/routes/cycle-feed.ts | 8 +- src/lib/validations/cycle-feed.ts | 13 ++ tests/integration/cycle-calendar-feed.test.ts | 90 +++++++++++ 30 files changed, 647 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/0495_cycle_feed_forecast_style/migration.sql create mode 100644 src/lib/cycle/feed/__tests__/events.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f74563e..3cbbb74f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Added + +- **Choose how the calendar subscription draws a forecast.** A logged + period of a few days next to a forecast spanning more than a week was + hard to read, because the forecast event covered every day the period + might fall on. The calendar subscription card now offers three styles + under "How forecasts look": just the expected days; the expected days + with the days the period may start earlier or run on later as separate + "possible" events (an open dot in discreet mode); or the single event + covering the whole range, as before. A subscription turned on from now + on starts with the expected days and their margin; one that is already + on keeps the single range until you change it, and rotating the link + keeps whichever style it has. `PATCH /api/cycle/feed-subscription` + accepts `forecastStyle` (`EXACT`, `MARGIN` or `BAND`) and the status + carries it. Migration `0495_cycle_feed_forecast_style`. (#1350) + ## [1.47.1] — 2026-11-07 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45964de7d..3f21603d9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -874,7 +874,7 @@ paths: summary: Turn the calendar subscription on (#1350) description: "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles - ahead, no history, no fertile window, discreet titles. Rate-limited per acting account + ahead, no history, no fertile window, discreet titles, `MARGIN` forecasts. Rate-limited per acting account (`cycle-feed:mint:`, 10/h, shared with rotate)." responses: "201": &a13 @@ -957,9 +957,9 @@ paths: tags: - Cycle summary: Replace the calendar subscription's link (#1350) - description: "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL - EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers - 409 `cycle.feed.exists` and the winner's link stands." + description: "One transaction: the live link is revoked and a new one minted with the same options, the forecast style + included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, + the one that loses answers 409 `cycle.feed.exists` and the winner's link stands." responses: "201": *a13 "401": *a14 @@ -28732,6 +28732,12 @@ components: enum: - DISCREET - DESCRIPTIVE + forecastStyle: + type: string + enum: + - EXACT + - MARGIN + - BAND additionalProperties: false description: "The writable options. At least one field. `includeFertile: true` is refused (422 `cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE." @@ -41370,6 +41376,17 @@ components: enum: - DISCREET - DESCRIPTIVE + forecastStyle: + type: string + enum: + - EXACT + - MARGIN + - BAND + description: "How a forecast period is drawn. `EXACT`: one event over the predicted bleeding days from the most likely + start. `MARGIN`: that event, plus a separate event for the days before it the period may start on and one + for the days after it may still run on (each only when the band reaches that side). `BAND`: one event from + the earliest likely start through the last bleeding day of the latest likely start. A subscription turned on + before the option existed is `BAND`." required: - tokenPrefix - createdAt @@ -41380,6 +41397,7 @@ components: - historyCycles - includeFertile - titleMode + - forecastStyle additionalProperties: false description: "The live calendar subscription, without its secret: never the token hash and never the URL." CycleFeedMinted: diff --git a/messages/de.json b/messages/de.json index 6d0139ccd..60cecd436 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10056,6 +10056,17 @@ "DISCREET": "Diskret (●)", "DESCRIPTIVE": "Beschreibend" }, + "forecasts": "Darstellung der Schätzung", + "forecastStyle": { + "EXACT": "Nur erwartete Tage", + "MARGIN": "Erwartete Tage mit Spielraum", + "BAND": "Ein Zeitraum" + }, + "forecastStyleHint": { + "EXACT": "Nur die erwarteten Tage.", + "MARGIN": "Die erwarteten Tage, dazu als eigene Termine die Tage davor und danach, falls die Periode früher oder später beginnt.", + "BAND": "Ein Termin über alle Tage, auf die die Periode fallen kann." + }, "fertile": "Fruchtbares Fenster", "fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.", "rotate": "Link erneuern", @@ -10075,6 +10086,7 @@ "periodEstimate": "Periode (Schätzung)", "periodLogged": "Periode (erfasst)", "fertileEstimate": "Fruchtbares Fenster (Schätzung)", + "periodPossible": "Periode möglich", "likelyStart": "Wahrscheinlicher Beginn {date}, {band}", "bandOne": "±{count} Tag", "bandFew": "±{count} Tage", diff --git a/messages/en.json b/messages/en.json index 9e86a3f69..295e1c28c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10056,6 +10056,17 @@ "DISCREET": "Discreet (●)", "DESCRIPTIVE": "Descriptive" }, + "forecasts": "How forecasts look", + "forecastStyle": { + "EXACT": "Expected days", + "MARGIN": "Expected days with margin", + "BAND": "One range" + }, + "forecastStyleHint": { + "EXACT": "Just the expected days.", + "MARGIN": "Expected days, with the days it may start earlier or later as separate entries.", + "BAND": "One entry covering every day it may fall on." + }, "fertile": "Fertile window", "fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.", "rotate": "Rotate link", @@ -10075,6 +10086,7 @@ "periodEstimate": "Period (estimate)", "periodLogged": "Period (logged)", "fertileEstimate": "Fertile window (estimate)", + "periodPossible": "Period possible", "likelyStart": "Most likely start {date}, {band}", "bandOne": "±{count} day", "bandFew": "±{count} days", diff --git a/messages/es.json b/messages/es.json index b20934c4d..b63237607 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10056,6 +10056,17 @@ "DISCREET": "Discreto (●)", "DESCRIPTIVE": "Descriptivo" }, + "forecasts": "Cómo se ven las previsiones", + "forecastStyle": { + "EXACT": "Días previstos", + "MARGIN": "Días previstos y margen", + "BAND": "Un solo intervalo" + }, + "forecastStyleHint": { + "EXACT": "Solo los días previstos.", + "MARGIN": "Los días previstos, con los días en que puede empezar antes o después como eventos aparte.", + "BAND": "Un solo evento que cubre todos los días en que puede caer." + }, "fertile": "Ventana fértil", "fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.", "rotate": "Renovar enlace", @@ -10075,6 +10086,7 @@ "periodEstimate": "Periodo (estimación)", "periodLogged": "Periodo (registrado)", "fertileEstimate": "Ventana fértil (estimación)", + "periodPossible": "Periodo posible", "likelyStart": "Inicio más probable {date}, {band}", "bandOne": "±{count} día", "bandFew": "±{count} días", diff --git a/messages/fr.json b/messages/fr.json index 24dba3492..f0ef51d9c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10056,6 +10056,17 @@ "DISCREET": "Discret (●)", "DESCRIPTIVE": "Explicite" }, + "forecasts": "Affichage des prévisions", + "forecastStyle": { + "EXACT": "Jours prévus", + "MARGIN": "Jours prévus et marge", + "BAND": "Une seule plage" + }, + "forecastStyleHint": { + "EXACT": "Seulement les jours prévus.", + "MARGIN": "Les jours prévus, avec en événements séparés les jours où les règles peuvent commencer plus tôt ou plus tard.", + "BAND": "Un seul événement couvrant tous les jours où elles peuvent tomber." + }, "fertile": "Fenêtre fertile", "fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.", "rotate": "Renouveler le lien", @@ -10075,6 +10086,7 @@ "periodEstimate": "Règles (estimation)", "periodLogged": "Règles (enregistrées)", "fertileEstimate": "Fenêtre fertile (estimation)", + "periodPossible": "Règles possibles", "likelyStart": "Début le plus probable {date}, {band}", "bandOne": "±{count} jour", "bandFew": "±{count} jours", diff --git a/messages/it.json b/messages/it.json index f0c0734a4..dee5f1d36 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10056,6 +10056,17 @@ "DISCREET": "Discreto (●)", "DESCRIPTIVE": "Descrittivo" }, + "forecasts": "Aspetto delle previsioni", + "forecastStyle": { + "EXACT": "Giorni previsti", + "MARGIN": "Giorni previsti e margine", + "BAND": "Un unico intervallo" + }, + "forecastStyleHint": { + "EXACT": "Solo i giorni previsti.", + "MARGIN": "I giorni previsti, con i giorni in cui possono iniziare prima o dopo come eventi separati.", + "BAND": "Un unico evento che copre tutti i giorni in cui possono cadere." + }, "fertile": "Finestra fertile", "fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.", "rotate": "Rinnova link", @@ -10075,6 +10086,7 @@ "periodEstimate": "Mestruazioni (stima)", "periodLogged": "Mestruazioni (registrate)", "fertileEstimate": "Finestra fertile (stima)", + "periodPossible": "Mestruazioni possibili", "likelyStart": "Inizio più probabile {date}, {band}", "bandOne": "±{count} giorno", "bandFew": "±{count} giorni", diff --git a/messages/ko.json b/messages/ko.json index f8dfaefe1..a14f6a891 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10056,6 +10056,17 @@ "DISCREET": "드러나지 않게 (●)", "DESCRIPTIVE": "내용 표시" }, + "forecasts": "예측 표시 방식", + "forecastStyle": { + "EXACT": "예상일만", + "MARGIN": "예상일과 여유 기간", + "BAND": "하나의 기간" + }, + "forecastStyleHint": { + "EXACT": "예상되는 날만 표시합니다.", + "MARGIN": "예상일과 함께, 더 일찍 또는 늦게 시작할 수 있는 날을 별도 일정으로 표시합니다.", + "BAND": "생리가 있을 수 있는 모든 날을 하나의 일정으로 표시합니다." + }, "fertile": "가임기", "fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.", "rotate": "링크 다시 발급", @@ -10075,6 +10086,7 @@ "periodEstimate": "생리 (예상)", "periodLogged": "생리 (기록)", "fertileEstimate": "가임기 (예상)", + "periodPossible": "생리 가능", "likelyStart": "가장 가능성 높은 시작일 {date}, {band}", "bandOne": "±{count}일", "bandFew": "±{count}일", diff --git a/messages/nl.json b/messages/nl.json index 462f15c05..86a396ba9 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10056,6 +10056,17 @@ "DISCREET": "Discreet (●)", "DESCRIPTIVE": "Beschrijvend" }, + "forecasts": "Weergave van voorspellingen", + "forecastStyle": { + "EXACT": "Verwachte dagen", + "MARGIN": "Verwachte dagen met marge", + "BAND": "Eén bereik" + }, + "forecastStyleHint": { + "EXACT": "Alleen de verwachte dagen.", + "MARGIN": "De verwachte dagen, met de dagen waarop ze eerder of later kan beginnen als aparte afspraken.", + "BAND": "Eén afspraak over alle dagen waarop ze kan vallen." + }, "fertile": "Vruchtbare periode", "fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.", "rotate": "Link vernieuwen", @@ -10075,6 +10086,7 @@ "periodEstimate": "Menstruatie (schatting)", "periodLogged": "Menstruatie (geregistreerd)", "fertileEstimate": "Vruchtbare periode (schatting)", + "periodPossible": "Menstruatie mogelijk", "likelyStart": "Meest waarschijnlijke begin {date}, {band}", "bandOne": "±{count} dag", "bandFew": "±{count} dagen", diff --git a/messages/pl.json b/messages/pl.json index f83b58363..b1d73193e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10056,6 +10056,17 @@ "DISCREET": "Dyskretne (●)", "DESCRIPTIVE": "Opisowe" }, + "forecasts": "Wygląd prognoz", + "forecastStyle": { + "EXACT": "Przewidywane dni", + "MARGIN": "Przewidywane dni z marginesem", + "BAND": "Jeden zakres" + }, + "forecastStyleHint": { + "EXACT": "Tylko przewidywane dni.", + "MARGIN": "Przewidywane dni, a jako osobne wydarzenia dni, w które miesiączka może zacząć się wcześniej lub później.", + "BAND": "Jedno wydarzenie obejmujące wszystkie dni, na które może przypaść." + }, "fertile": "Okno płodne", "fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.", "rotate": "Odnów link", @@ -10075,6 +10086,7 @@ "periodEstimate": "Miesiączka (szacunek)", "periodLogged": "Miesiączka (zapisana)", "fertileEstimate": "Okno płodne (szacunek)", + "periodPossible": "Możliwa miesiączka", "likelyStart": "Najbardziej prawdopodobny początek {date}, {band}", "bandOne": "±{count} dzień", "bandFew": "±{count} dni", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 97989a554..20e581383 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10056,6 +10056,17 @@ "DISCREET": "Discreto (●)", "DESCRIPTIVE": "Descritivo" }, + "forecasts": "Como as previsões aparecem", + "forecastStyle": { + "EXACT": "Dias previstos", + "MARGIN": "Dias previstos e margem", + "BAND": "Um único intervalo" + }, + "forecastStyleHint": { + "EXACT": "Só os dias previstos.", + "MARGIN": "Os dias previstos, com os dias em que ela pode começar antes ou depois como eventos separados.", + "BAND": "Um único evento cobrindo todos os dias em que ela pode cair." + }, "fertile": "Janela fértil", "fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.", "rotate": "Renovar link", @@ -10075,6 +10086,7 @@ "periodEstimate": "Menstruação (estimativa)", "periodLogged": "Menstruação (registrada)", "fertileEstimate": "Janela fértil (estimativa)", + "periodPossible": "Menstruação possível", "likelyStart": "Início mais provável {date}, {band}", "bandOne": "±{count} dia", "bandFew": "±{count} dias", diff --git a/messages/ru.json b/messages/ru.json index b83a16b5a..c53ea555b 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10056,6 +10056,17 @@ "DISCREET": "Незаметно (●)", "DESCRIPTIVE": "Подробно" }, + "forecasts": "Вид прогноза", + "forecastStyle": { + "EXACT": "Ожидаемые дни", + "MARGIN": "Ожидаемые дни с запасом", + "BAND": "Один промежуток" + }, + "forecastStyleHint": { + "EXACT": "Только ожидаемые дни.", + "MARGIN": "Ожидаемые дни, а отдельными событиями дни, когда месячные могут начаться раньше или позже.", + "BAND": "Одно событие на все дни, на которые они могут прийтись." + }, "fertile": "Фертильное окно", "fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.", "rotate": "Обновить ссылку", @@ -10075,6 +10086,7 @@ "periodEstimate": "Месячные (оценка)", "periodLogged": "Месячные (записаны)", "fertileEstimate": "Фертильное окно (оценка)", + "periodPossible": "Возможны месячные", "likelyStart": "Наиболее вероятное начало {date}, {band}", "bandOne": "±{count} день", "bandFew": "±{count} дня", diff --git a/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql b/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql new file mode 100644 index 000000000..6e7558438 --- /dev/null +++ b/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql @@ -0,0 +1,33 @@ +-- How the cycle calendar feed draws a forecast period (#1350): +-- +-- EXACT the calculated period only, no margin +-- MARGIN the calculated period, plus the days it may start earlier or end +-- later as separate "possible" events +-- BAND one event covering every day the period may fall on +-- +-- The column defaults to BAND, which is what every feed drew until now, so an +-- existing subscription keeps exactly the events it had. A new subscription is +-- given MARGIN by the route that mints it, not by this default. +-- +-- One DO block, so the file is one statement that rolls back whole and the +-- lock wait on `cycle_calendar_feeds` is bounded. The type is created only when +-- missing and the column only when missing, so a replay is harmless. +-- Reversibility: dropping the column and the type returns every feed to the +-- single-band forecast. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_forecast_style' + ) THEN + CREATE TYPE "cycle_calendar_feed_forecast_style" AS ENUM ('EXACT', 'MARGIN', 'BAND'); + END IF; + + ALTER TABLE "cycle_calendar_feeds" + ADD COLUMN IF NOT EXISTS "forecast_style" "cycle_calendar_feed_forecast_style" NOT NULL DEFAULT 'BAND'; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8b102b41c..dbf05d6e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8343,6 +8343,17 @@ enum CycleCalendarFeedTitleMode { @@map("cycle_calendar_feed_title_mode") } +/// #1350 — how the feed draws a forecast period. EXACT is the calculated +/// period alone; MARGIN adds the days it may start earlier or end later as +/// separate events; BAND is one event over every day it may fall on. +enum CycleCalendarFeedForecastStyle { + EXACT + MARGIN + BAND + + @@map("cycle_calendar_feed_forecast_style") +} + /// #1350 — a subscribable calendar feed of one record's cycle forecast. /// /// The row is a URL credential. Only the HMAC of the raw `hlf_` token is @@ -8389,12 +8400,15 @@ model CycleCalendarFeed { /// Forecast cycles ahead (1..6). The feed caps itself further once the /// band grows past ±10 days or half a cycle. - horizonCycles Int @default(3) @map("horizon_cycles") + horizonCycles Int @default(3) @map("horizon_cycles") /// Observed cycles included as history (0..12). - historyCycles Int @default(0) @map("history_cycles") + historyCycles Int @default(0) @map("history_cycles") /// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only. - includeFertile Boolean @default(false) @map("include_fertile") - titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode") + includeFertile Boolean @default(false) @map("include_fertile") + titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode") + /// BAND by default so a feed minted before the option keeps its events. A + /// new feed is minted with MARGIN by the subscription route. + forecastStyle CycleCalendarFeedForecastStyle @default(BAND) @map("forecast_style") user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade) createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade) diff --git a/src/__tests__/cycle-feed-content-guard.test.ts b/src/__tests__/cycle-feed-content-guard.test.ts index d250d0116..83b938138 100644 --- a/src/__tests__/cycle-feed-content-guard.test.ts +++ b/src/__tests__/cycle-feed-content-guard.test.ts @@ -118,13 +118,20 @@ beforeEach(() => { }); describe("the calendar feed body", () => { - it.each(["DISCREET", "DESCRIPTIVE"] as const)( - "carries no day-log content in %s mode", - async (titleMode) => { + it.each( + (["DISCREET", "DESCRIPTIVE"] as const).flatMap((titleMode) => + (["EXACT", "MARGIN", "BAND"] as const).map( + (forecastStyle) => [titleMode, forecastStyle] as const, + ), + ), + )( + "carries no day-log content in %s mode, %s forecasts", + async (titleMode, forecastStyle) => { const built = await buildCycleFeed( { ...FEED, titleMode, + forecastStyle, historyCycles: 12, horizonCycles: 6, includeFertile: true, @@ -146,6 +153,7 @@ describe("the calendar feed body", () => { { ...FEED, titleMode: "DESCRIPTIVE", + forecastStyle: "MARGIN", historyCycles: 6, includeFertile: true, }, diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 9b62851c9..fd9449577 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -4,6 +4,8 @@ * * One transaction: the live feed is revoked and its successor minted with the * same options, so there is never a moment with two live links or with none. + * The forecast style is copied too: a feed minted before that option keeps + * BAND rather than taking a new feed's default. * Calendars holding the old link stop updating on their next poll. The new * URL is returned exactly once. Two rotations racing both revoke the same * feed, and the second successor then meets the one-live-feed index: that @@ -78,6 +80,7 @@ export const POST = apiHandler(async (request: NextRequest) => { historyCycles: true, includeFertile: true, titleMode: true, + forecastStyle: true, }, }); if (!live) return null; @@ -95,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => { historyCycles: live.historyCycles, includeFertile: live.includeFertile, titleMode: live.titleMode, + forecastStyle: live.forecastStyle, }, select: FEED_SUMMARY_SELECT, }); diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 8b8b3aba9..6d8b94516 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -59,7 +59,10 @@ import { destroyedDetails, overwriteDetails, } from "@/lib/sharing/audit-details"; -import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed"; +import { + NEW_FEED_FORECAST_STYLE, + cycleFeedOptionsPatchSchema, +} from "@/lib/validations/cycle-feed"; export const GET = apiHandler(async () => { const auth = await requireGuardianAuth(); @@ -110,6 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { userId: user.id, createdById: actor.id, + forecastStyle: NEW_FEED_FORECAST_STYLE, tokenHash: minted.hash, tokenPrefix: minted.prefix, }, @@ -180,6 +184,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ? { includeFertile: input.includeFertile } : {}), ...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}), + ...(input.forecastStyle !== undefined + ? { forecastStyle: input.forecastStyle } + : {}), }; const updated = await prisma.cycleCalendarFeed.update({ where: { id: live.id }, @@ -197,12 +204,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => { historyCycles: live.historyCycles, includeFertile: live.includeFertile, titleMode: live.titleMode, + forecastStyle: live.forecastStyle, }, after: { horizonCycles: updated.horizonCycles, historyCycles: updated.historyCycles, includeFertile: updated.includeFertile, titleMode: updated.titleMode, + forecastStyle: updated.forecastStyle, }, }), }, diff --git a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx index 6e30cf1ba..da7782cab 100644 --- a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx @@ -30,6 +30,7 @@ const FEED = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET" as const, + forecastStyle: "MARGIN" as const, }; describe("", () => { @@ -58,6 +59,30 @@ describe("", () => { expect(html).not.toContain("Fertile window"); }); + it("offers the three forecast styles and explains the chosen one", () => { + const html = render({ + feed: FEED, + projectionState: "OK", + fertileAvailable: false, + }); + expect(html).toContain("How forecasts look"); + for (const value of ["EXACT", "MARGIN", "BAND"]) { + expect(html).toContain(`value="${value}"`); + } + expect(html).toContain('aria-describedby="cycle-feed-forecast-hint"'); + expect(html).toMatch( + /data-slot="cycle-feed-forecast-hint"[^>]*>Expected days, with the days it may start earlier or later as separate entries\. { const html = render({ feed: FEED, diff --git a/src/components/cycle/cycle-calendar-feed-card.tsx b/src/components/cycle/cycle-calendar-feed-card.tsx index ec6bcf74e..454045904 100644 --- a/src/components/cycle/cycle-calendar-feed-card.tsx +++ b/src/components/cycle/cycle-calendar-feed-card.tsx @@ -28,6 +28,7 @@ import { type CycleFeedMinted, type CycleFeedOptionsPatch, type CycleFeedStatus, + type FeedForecastStyle, type FeedTitleMode, } from "./use-cycle-feed"; @@ -44,6 +45,7 @@ import { const HORIZONS = [1, 3, 6] as const; const HISTORIES = [0, 3, 6, 12] as const; const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"]; +const FORECAST_STYLES: FeedForecastStyle[] = ["EXACT", "MARGIN", "BAND"]; /** A long URL with its middle elided, so both ends stay recognisable. */ function middleTruncate(url: string, keep = 28): string { @@ -352,6 +354,31 @@ function FeedOptions({ status }: { status: CycleFeedStatus }) { +
+ + + save({ forecastStyle: e.target.value as FeedForecastStyle }) + } + > + {FORECAST_STYLES.map((s) => ( + + ))} + +

+ {t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)} +

+
{status.fertileAvailable ? (
diff --git a/src/components/cycle/use-cycle-feed.ts b/src/components/cycle/use-cycle-feed.ts index 58ab6ed64..997911a3c 100644 --- a/src/components/cycle/use-cycle-feed.ts +++ b/src/components/cycle/use-cycle-feed.ts @@ -17,6 +17,7 @@ import { queryKeys } from "@/lib/query-keys"; import type { ProjectionState } from "./types"; export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE"; +export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND"; export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER"; export interface CycleFeedSummary { @@ -29,6 +30,7 @@ export interface CycleFeedSummary { historyCycles: number; includeFertile: boolean; titleMode: FeedTitleMode; + forecastStyle: FeedForecastStyle; } export interface CycleFeedStatus { @@ -46,7 +48,11 @@ export interface CycleFeedMinted { export type CycleFeedOptionsPatch = Partial< Pick< CycleFeedSummary, - "horizonCycles" | "historyCycles" | "includeFertile" | "titleMode" + | "horizonCycles" + | "historyCycles" + | "includeFertile" + | "titleMode" + | "forecastStyle" > >; diff --git a/src/lib/cycle/feed/__tests__/events.test.ts b/src/lib/cycle/feed/__tests__/events.test.ts new file mode 100644 index 000000000..e52df5078 --- /dev/null +++ b/src/lib/cycle/feed/__tests__/events.test.ts @@ -0,0 +1,145 @@ +/** + * #1350 — how a forecast period is drawn in each style. Dates only: the + * projection is handed in, so every expected day below is the arithmetic of + * one fixed cycle and nothing else. + */ +import { describe, expect, it } from "vitest"; + +import type { ProjectedCycle } from "@/lib/cycle/projection"; +import { eventUid } from "@/lib/cycle/ics"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +import { + DISCREET_MARGIN_SUMMARY, + DISCREET_SUMMARY, + buildFeedCalendar, + type FeedForecastStyle, + type FeedTitleMode, +} from "../events"; + +/** Most likely start 17 Nov, five bleeding days, ±2 days. */ +const CYCLE: ProjectedCycle = { + k: 1, + start: "2026-11-17", + periodLength: 5, + bandStart: "2026-11-15", + bandEnd: "2026-11-19", +}; + +function events( + forecastStyle: FeedForecastStyle, + projected: ProjectedCycle[] = [CYCLE], + titleMode: FeedTitleMode = "DISCREET", + locale: "en" | "de" = "en", +) { + return buildFeedCalendar({ + feedId: "feed-1", + titleMode, + forecastStyle, + projected, + history: [], + fertile: null, + translator: getServerTranslator(locale), + }).events; +} + +/** start .. endExclusive, the pair DTSTART / DTEND carry. */ +const span = (e: { start: string; endExclusive: string }) => [ + e.start, + e.endExclusive, +]; + +describe("forecast styles", () => { + it("BAND draws one event from the earliest start to the latest period's end", () => { + const out = events("BAND"); + expect(out.map(span)).toEqual([["2026-11-15", "2026-11-24"]]); + expect(out[0].summary).toBe(DISCREET_SUMMARY); + }); + + it("EXACT draws only the calculated period", () => { + const out = events("EXACT"); + expect(out.map(span)).toEqual([["2026-11-17", "2026-11-22"]]); + expect(out[0].summary).toBe(DISCREET_SUMMARY); + }); + + it("MARGIN draws the calculated period with the possible days on each side", () => { + const out = events("MARGIN"); + expect(out.map(span)).toEqual([ + ["2026-11-15", "2026-11-17"], + ["2026-11-17", "2026-11-22"], + ["2026-11-22", "2026-11-24"], + ]); + expect(out.map((e) => e.summary)).toEqual([ + DISCREET_MARGIN_SUMMARY, + DISCREET_SUMMARY, + DISCREET_MARGIN_SUMMARY, + ]); + }); + + it("MARGIN covers exactly the days BAND covers, without overlap", () => { + const [band] = events("BAND"); + const margin = events("MARGIN"); + expect(margin[0].start).toBe(band.start); + expect(margin.at(-1)!.endExclusive).toBe(band.endExclusive); + for (let i = 1; i < margin.length; i++) { + expect(margin[i].start).toBe(margin[i - 1].endExclusive); + } + }); + + it("MARGIN draws no side event where the band is empty", () => { + const exact = { ...CYCLE, bandStart: CYCLE.start, bandEnd: CYCLE.start }; + expect(events("MARGIN", [exact]).map(span)).toEqual([ + ["2026-11-17", "2026-11-22"], + ]); + const lateOnly = { ...CYCLE, bandStart: CYCLE.start }; + expect(events("MARGIN", [lateOnly]).map(span)).toEqual([ + ["2026-11-17", "2026-11-22"], + ["2026-11-22", "2026-11-24"], + ]); + const earlyOnly = { ...CYCLE, bandEnd: CYCLE.start }; + expect(events("MARGIN", [earlyOnly]).map(span)).toEqual([ + ["2026-11-15", "2026-11-17"], + ["2026-11-17", "2026-11-22"], + ]); + }); + + it("keeps the period's UID across styles and gives each margin its own", () => { + const main = eventUid("feed-1", "pred:1"); + expect(events("BAND")[0].uid).toBe(main); + expect(events("EXACT")[0].uid).toBe(main); + expect(events("MARGIN").map((e) => e.uid)).toEqual([ + eventUid("feed-1", "pred:1:before"), + main, + eventUid("feed-1", "pred:1:after"), + ]); + }); + + it("draws every forecast cycle it is given", () => { + const next = { + k: 2, + start: "2026-12-15", + periodLength: 5, + bandStart: "2026-12-12", + bandEnd: "2026-12-18", + }; + expect(events("EXACT", [CYCLE, next])).toHaveLength(2); + expect(events("MARGIN", [CYCLE, next])).toHaveLength(6); + expect(events("BAND", [CYCLE, next])).toHaveLength(2); + }); + + it("names the possible days in the record's language when descriptive", () => { + expect( + events("MARGIN", [CYCLE], "DESCRIPTIVE").map((e) => e.summary), + ).toEqual(["Period possible", "Period (estimate)", "Period possible"]); + expect( + events("MARGIN", [CYCLE], "DESCRIPTIVE", "de").map((e) => e.summary), + ).toEqual(["Periode möglich", "Periode (Schätzung)", "Periode möglich"]); + }); + + it("describes every forecast event with its band", () => { + for (const e of events("MARGIN", [CYCLE], "DESCRIPTIVE")) { + expect(e.description).toBe("Most likely start Nov 17, ±2 days"); + } + for (const e of events("EXACT")) expect(e.description).toBe("±2 days"); + }); +}); diff --git a/src/lib/cycle/feed/__tests__/fixtures.ts b/src/lib/cycle/feed/__tests__/fixtures.ts index ad65c01e5..e4754d129 100644 --- a/src/lib/cycle/feed/__tests__/fixtures.ts +++ b/src/lib/cycle/feed/__tests__/fixtures.ts @@ -48,6 +48,7 @@ export const FEED = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET" as const, + forecastStyle: "BAND" as const, }; export const SUBJECT = { diff --git a/src/lib/cycle/feed/__tests__/serve.test.ts b/src/lib/cycle/feed/__tests__/serve.test.ts index a822043cc..335d7371a 100644 --- a/src/lib/cycle/feed/__tests__/serve.test.ts +++ b/src/lib/cycle/feed/__tests__/serve.test.ts @@ -54,6 +54,7 @@ const FEED_ROW = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET", + forecastStyle: "BAND", }; function request(path = `${TOKEN}.ics`, headers: Record = {}) { diff --git a/src/lib/cycle/feed/build.ts b/src/lib/cycle/feed/build.ts index 3505a65fb..ab219e017 100644 --- a/src/lib/cycle/feed/build.ts +++ b/src/lib/cycle/feed/build.ts @@ -30,7 +30,11 @@ import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { icsContentHash, type IcsCalendar } from "../ics"; -import { buildFeedCalendar, type FeedTitleMode } from "./events"; +import { + buildFeedCalendar, + type FeedForecastStyle, + type FeedTitleMode, +} from "./events"; import { historyPeriods, historyWindowStart } from "./history"; /** The feed options the body depends on. */ @@ -40,6 +44,7 @@ export interface FeedOptions { historyCycles: number; includeFertile: boolean; titleMode: FeedTitleMode; + forecastStyle: FeedForecastStyle; } /** The record the feed serves, as far as building it needs. */ @@ -147,6 +152,7 @@ export async function buildCycleFeed( const calendar = buildFeedCalendar({ feedId: feed.id, titleMode: feed.titleMode, + forecastStyle: feed.forecastStyle, projected: projection.cycles, history, fertile, diff --git a/src/lib/cycle/feed/events.ts b/src/lib/cycle/feed/events.ts index 44f399a09..70cdd6111 100644 --- a/src/lib/cycle/feed/events.ts +++ b/src/lib/cycle/feed/events.ts @@ -11,6 +11,13 @@ * DISCREET (the default) titles every event with a dot and names the calendar * "HealthLog", so a glance at a shared screen or a lock-screen notification * says nothing. DESCRIPTIVE names the period, in the record's own language. + * + * A forecast period is drawn in one of three styles. EXACT is the period as + * calculated: the most likely first day for the predicted number of bleeding + * days. MARGIN is the same event, plus the days the period may start earlier + * or run on later as their own "possible" events, so the calculated days stay + * readable as such. BAND is one event over every day the period may fall on, + * from the earliest likely start to the last bleeding day of the latest. */ import { addDays, dayDiff } from "@/lib/cycle/day-math"; import type { ProjectedCycle } from "@/lib/cycle/projection"; @@ -21,9 +28,12 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator"; import { eventUid, type IcsCalendar, type IcsEvent } from "../ics"; export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE"; +export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND"; /** The discreet title: one dot, no word. */ export const DISCREET_SUMMARY = "●"; +/** The discreet title of a MARGIN style's possible days: an open dot. */ +export const DISCREET_MARGIN_SUMMARY = "○"; /** The discreet calendar name. */ export const DISCREET_CALENDAR_NAME = "HealthLog"; @@ -39,6 +49,7 @@ export interface HistoryPeriod { export interface FeedEventInput { feedId: string; titleMode: FeedTitleMode; + forecastStyle: FeedForecastStyle; projected: readonly ProjectedCycle[]; history: readonly HistoryPeriod[]; /** The next fertile window, already gated; null when not shown. */ @@ -63,26 +74,81 @@ function bandText(days: number, translator: ServerTranslator): string { ); } -function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent { +/** The forecast's description: the likely start and the band, or the band. */ +function forecastDescription(p: ProjectedCycle, input: FeedEventInput): string { + const { translator } = input; + const band = bandText(dayDiff(p.bandEnd, p.start), translator); + return input.titleMode === "DESCRIPTIVE" + ? translator.t("cycle.feed.ics.likelyStart", { + date: shortDate(p.start, translator), + band, + }) + : band; +} + +/** + * One forecast cycle's events. DTEND is exclusive throughout, so the period + * itself ends the day after its last bleeding day. + */ +function forecastEvents(p: ProjectedCycle, input: FeedEventInput): IcsEvent[] { const { translator } = input; - const band = dayDiff(p.bandEnd, p.start); const descriptive = input.titleMode === "DESCRIPTIVE"; - return { - uid: eventUid(input.feedId, `pred:${p.k}`), - // The whole window the period may fall in: earliest likely start through - // the last bleeding day of the latest likely start. DTEND is exclusive. - start: p.bandStart, - endExclusive: addDays(p.bandEnd, p.periodLength), - summary: descriptive - ? translator.t("cycle.feed.ics.periodEstimate") - : DISCREET_SUMMARY, - description: descriptive - ? translator.t("cycle.feed.ics.likelyStart", { - date: shortDate(p.start, translator), - band: bandText(band, translator), - }) - : bandText(band, translator), - }; + const description = forecastDescription(p, input); + const periodSummary = descriptive + ? translator.t("cycle.feed.ics.periodEstimate") + : DISCREET_SUMMARY; + + if (input.forecastStyle === "BAND") { + return [ + { + uid: eventUid(input.feedId, `pred:${p.k}`), + // The whole window the period may fall in: earliest likely start + // through the last bleeding day of the latest likely start. + start: p.bandStart, + endExclusive: addDays(p.bandEnd, p.periodLength), + summary: periodSummary, + description, + }, + ]; + } + + const periodEnd = addDays(p.start, p.periodLength); + const events: IcsEvent[] = [ + { + uid: eventUid(input.feedId, `pred:${p.k}`), + start: p.start, + endExclusive: periodEnd, + summary: periodSummary, + description, + }, + ]; + if (input.forecastStyle === "EXACT") return events; + + const marginSummary = descriptive + ? translator.t("cycle.feed.ics.periodPossible") + : DISCREET_MARGIN_SUMMARY; + // The days before the likely start it may begin on, and the days after the + // likely period it may still run on. A side with no band has no event. + if (dayDiff(p.start, p.bandStart) > 0) { + events.push({ + uid: eventUid(input.feedId, `pred:${p.k}:before`), + start: p.bandStart, + endExclusive: p.start, + summary: marginSummary, + description, + }); + } + const bandEndExclusive = addDays(p.bandEnd, p.periodLength); + if (dayDiff(bandEndExclusive, periodEnd) > 0) { + events.push({ + uid: eventUid(input.feedId, `pred:${p.k}:after`), + start: periodEnd, + endExclusive: bandEndExclusive, + summary: marginSummary, + description, + }); + } + return events; } function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent { @@ -116,7 +182,7 @@ function fertileEvent( export function buildFeedCalendar(input: FeedEventInput): IcsCalendar { const events: IcsEvent[] = [ ...input.history.map((h) => historyEvent(h, input)), - ...input.projected.map((p) => forecastEvent(p, input)), + ...input.projected.flatMap((p) => forecastEvents(p, input)), ...(input.fertile ? [fertileEvent(input.fertile, input)] : []), ].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid)); return { diff --git a/src/lib/cycle/feed/serve.ts b/src/lib/cycle/feed/serve.ts index c953d44f5..9435ca293 100644 --- a/src/lib/cycle/feed/serve.ts +++ b/src/lib/cycle/feed/serve.ts @@ -116,6 +116,7 @@ const FEED_SELECT = { historyCycles: true, includeFertile: true, titleMode: true, + forecastStyle: true, } as const; /** diff --git a/src/lib/cycle/feed/status.ts b/src/lib/cycle/feed/status.ts index b53a6ef98..7b37514fb 100644 --- a/src/lib/cycle/feed/status.ts +++ b/src/lib/cycle/feed/status.ts @@ -10,7 +10,7 @@ import type { ProjectionState } from "@/lib/cycle/projection"; import { buildCycleFeed, type FeedSubject } from "./build"; import { fetcherFamily, type FetcherFamily } from "./fetcher"; -import type { FeedTitleMode } from "./events"; +import type { FeedForecastStyle, FeedTitleMode } from "./events"; export const FEED_SUMMARY_SELECT = { id: true, @@ -23,6 +23,7 @@ export const FEED_SUMMARY_SELECT = { historyCycles: true, includeFertile: true, titleMode: true, + forecastStyle: true, } as const; export interface FeedSummaryRow { @@ -36,6 +37,7 @@ export interface FeedSummaryRow { historyCycles: number; includeFertile: boolean; titleMode: FeedTitleMode; + forecastStyle: FeedForecastStyle; } export interface CycleFeedSummary { @@ -49,6 +51,7 @@ export interface CycleFeedSummary { historyCycles: number; includeFertile: boolean; titleMode: FeedTitleMode; + forecastStyle: FeedForecastStyle; } export interface CycleFeedStatus { @@ -74,6 +77,7 @@ export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary { historyCycles: row.historyCycles, includeFertile: row.includeFertile, titleMode: row.titleMode, + forecastStyle: row.forecastStyle, }; } diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index 3e250c994..504bb005e 100644 --- a/src/lib/openapi/routes/cycle-feed.ts +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -11,6 +11,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { + cycleFeedForecastStyleEnum, cycleFeedOptionsPatchSchema, cycleFeedTitleModeEnum, } from "@/lib/validations/cycle-feed"; @@ -76,6 +77,9 @@ const feedSummary = z historyCycles: z.number().int().min(0).max(12), includeFertile: z.boolean(), titleMode: cycleFeedTitleModeEnum, + forecastStyle: cycleFeedForecastStyleEnum.describe( + "How a forecast period is drawn. `EXACT`: one event over the predicted bleeding days from the most likely start. `MARGIN`: that event, plus a separate event for the days before it the period may start on and one for the days after it may still run on (each only when the band reaches that side). `BAND`: one event from the earliest likely start through the last bleeding day of the latest likely start. A subscription turned on before the option existed is `BAND`.", + ), }) .meta({ id: "CycleFeedSummary", @@ -202,7 +206,7 @@ export const cycleFeedPaths: NonNullable = { tags: ["Cycle"], summary: "Turn the calendar subscription on (#1350)", description: - "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:`, 10/h, shared with rotate).", + "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles, `MARGIN` forecasts. Rate-limited per acting account (`cycle-feed:mint:`, 10/h, shared with rotate).", responses: { ...mintResponses, "409": { @@ -271,7 +275,7 @@ export const cycleFeedPaths: NonNullable = { tags: ["Cycle"], summary: "Replace the calendar subscription's link (#1350)", description: - "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.", + "One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.", responses: { ...mintResponses, "404": { diff --git a/src/lib/validations/cycle-feed.ts b/src/lib/validations/cycle-feed.ts index 646faf3ea..32866e808 100644 --- a/src/lib/validations/cycle-feed.ts +++ b/src/lib/validations/cycle-feed.ts @@ -10,6 +10,18 @@ import { z } from "zod/v4"; /** The feed's title modes. Mirrors `CycleCalendarFeedTitleMode`. */ export const cycleFeedTitleModeEnum = z.enum(["DISCREET", "DESCRIPTIVE"]); +/** How a forecast period is drawn. Mirrors `CycleCalendarFeedForecastStyle`. */ +export const cycleFeedForecastStyleEnum = z.enum(["EXACT", "MARGIN", "BAND"]); + +/** + * The style a newly minted feed starts with. The column's own default is BAND, + * which keeps every feed minted before the option on the events it had; a new + * one starts with the calculated days and their margin drawn apart. + */ +export const NEW_FEED_FORECAST_STYLE: z.infer< + typeof cycleFeedForecastStyleEnum +> = "MARGIN"; + /** Forecast cycles ahead. The feed caps itself further by band width. */ export const FEED_HORIZON_MIN = 1; export const FEED_HORIZON_MAX = 6; @@ -33,6 +45,7 @@ export const cycleFeedOptionsPatchSchema = z .optional(), includeFertile: z.boolean().optional(), titleMode: cycleFeedTitleModeEnum.optional(), + forecastStyle: cycleFeedForecastStyleEnum.optional(), }) .strict() .refine((v) => Object.keys(v).length > 0, { diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 257ab7234..7e271e72b 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -407,6 +407,96 @@ describe("turning the subscription on", () => { }); }); +describe("the forecast style", () => { + /** The live row's style, read from the table rather than the route. */ + async function liveStyle(userId: string) { + const row = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId, revokedAt: null }, + select: { forecastStyle: true }, + }); + return row.forecastStyle; + } + + /** How many VEVENTs the live link serves. */ + async function eventCount(url: string) { + const res = await fetchFeed(segmentOf(url)); + expect(res.status).toBe(200); + return (await res.text()).split("BEGIN:VEVENT").length - 1; + } + + it("starts a new subscription on MARGIN and publishes it in the status", async () => { + const owner = await makeUser("owner"); + await seedCycles(owner.id); + await signIn(owner.id); + const res = await manage("POST"); + expect(res.status).toBe(201); + expect((await res.json()).data.feed.forecastStyle).toBe("MARGIN"); + expect(await liveStyle(owner.id)).toBe("MARGIN"); + const status = (await (await manage("GET")).json()).data; + expect(status.feed.forecastStyle).toBe("MARGIN"); + }); + + it("keeps a feed minted before the option on BAND, through a rotation", async () => { + const owner = await makeUser("owner"); + // A row written without the column, as every feed minted before it was. + await db().cycleCalendarFeed.create({ + data: { + userId: owner.id, + createdById: owner.id, + tokenHash: "pre-option", + tokenPrefix: "hlf_0000", + }, + }); + expect(await liveStyle(owner.id)).toBe("BAND"); + + await signIn(owner.id); + const res = await rotate(); + expect(res.status).toBe(201); + expect((await res.json()).data.feed.forecastStyle).toBe("BAND"); + expect(await liveStyle(owner.id)).toBe("BAND"); + }); + + it("changes on PATCH, is audited, survives a rotation, and changes the body", async () => { + const owner = await makeUser("owner"); + await seedCycles(owner.id); + const minted = await mint(owner.id); + const marginEvents = await eventCount(minted.url); + + await signIn(owner.id); + const res = await manage("PATCH", { forecastStyle: "EXACT" }); + expect(res.status).toBe(200); + expect((await res.json()).data.feed.forecastStyle).toBe("EXACT"); + expect(await liveStyle(owner.id)).toBe("EXACT"); + // The calculated period alone: fewer events than with its margins, and + // one per forecast cycle. + const exactEvents = await eventCount(minted.url); + expect(exactEvents).toBeGreaterThan(0); + expect(marginEvents).toBeGreaterThan(exactEvents); + + const audit = await db().auditLog.findFirstOrThrow({ + where: { action: "cycle.feed.update", userId: owner.id }, + }); + // The audit names the changed field and the value it replaced. + const details = JSON.parse(audit.details ?? "{}"); + expect(details.fields).toEqual(["forecastStyle"]); + expect(details.previous).toEqual({ forecastStyle: "MARGIN" }); + + await signIn(owner.id); + const rotated = await rotate(); + expect(rotated.status).toBe(201); + expect((await rotated.json()).data.feed.forecastStyle).toBe("EXACT"); + expect(await liveStyle(owner.id)).toBe("EXACT"); + }); + + it("refuses a style that does not exist", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const res = await manage("PATCH", { forecastStyle: "WIDE" }); + expect(res.status).toBe(422); + expect(await liveStyle(owner.id)).toBe("MARGIN"); + }); +}); + describe("fetching the feed", () => { it("serves iCalendar with an ETag and answers 304 to it", async () => { const owner = await makeUser("owner"); From d53eb1ec7606a6e32b921b390ccb60230a1608df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 18:30:25 +0100 Subject: [PATCH 02/46] test: show why a pre-migration dump failed in the retention test --- tests/integration/database-backup.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/database-backup.test.ts b/tests/integration/database-backup.test.ts index 8e3ec5b76..d59df92e4 100644 --- a/tests/integration/database-backup.test.ts +++ b/tests/integration/database-backup.test.ts @@ -596,8 +596,10 @@ describe("the dump before migrating", () => { it("writes a checked dump with its manifest, and keeps the last two", async () => { for (let i = 0; i < 3; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 1100)); - const { code } = await preMigration({ DB_BACKUP_DIR: preDir }); - expect(code).toBe(0); + const { code, errors } = await preMigration({ DB_BACKUP_DIR: preDir }); + // One shuffled nightly run saw a 1 here with the reason swallowed by + // the console spy; the message carries it next time. + expect(code, errors).toBe(0); } const names = readdirSync(preDir).sort(); expect(names).toHaveLength(4); From 2dfcadbea15177af3a555dbdbe2083c898644120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 18:34:00 +0100 Subject: [PATCH 03/46] feat(mental-health): crisis contacts for South Korea Korean, and any locale naming the region kr, resolves to 119 and the national suicide prevention line 109 instead of the international set. --- CHANGELOG.md | 6 +++++ messages/de.json | 3 +++ messages/en.json | 3 +++ messages/es.json | 3 +++ messages/fr.json | 3 +++ messages/it.json | 3 +++ messages/ko.json | 3 +++ messages/nl.json | 3 +++ messages/pl.json | 3 +++ messages/pt-BR.json | 3 +++ messages/ru.json | 3 +++ .../__tests__/crisis-resources.test.ts | 16 +++++++++++ src/lib/mental-health/crisis-resources.ts | 27 +++++++++++++++++-- 13 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbbb74f3..33a91d90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ keeps whichever style it has. `PATCH /api/cycle/feed-subscription` accepts `forecastStyle` (`EXACT`, `MARGIN` or `BAND`) and the status carries it. Migration `0495_cycle_feed_forecast_style`. (#1350) +- **Crisis contacts for South Korea.** A questionnaire answer that shows + the crisis card now gives a Korean reader, or anyone whose locale names + South Korea, the emergency number 119 and the national suicide + prevention line 109, which answers around the clock. Before, Korean + fell through to the international set with 112, which reaches the + police there, not an ambulance. ## [1.47.1] — 2026-11-07 diff --git a/messages/de.json b/messages/de.json index 60cecd436..578b94d21 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Belgien, rund um die Uhr, kostenlos, anonym)" + }, + "suicidePrevention109": { + "name": "Suizidpräventions-Telefon 109 (Südkorea, rund um die Uhr, kostenlos)" } }, "instrumentDescription": { diff --git a/messages/en.json b/messages/en.json index 295e1c28c..9d90b4bf4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Belgium, 24/7, free, anonymous)" + }, + "suicidePrevention109": { + "name": "Suicide prevention line 109 (South Korea, 24/7, free)" } }, "instrumentDescription": { diff --git a/messages/es.json b/messages/es.json index b63237607..9dc9cf7c6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anónimo)" + }, + "suicidePrevention109": { + "name": "Línea de prevención del suicidio 109 (Corea del Sur, 24 horas, gratuita)" } }, "instrumentDescription": { diff --git a/messages/fr.json b/messages/fr.json index f0ef51d9c..ebd9846e5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Belgique, 24h/24, 7j/7, gratuit et anonyme)" + }, + "suicidePrevention109": { + "name": "Ligne de prévention du suicide 109 (Corée du Sud, 24 h/24, gratuite)" } }, "instrumentDescription": { diff --git a/messages/it.json b/messages/it.json index dee5f1d36..f77f140ee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Belgio, 24 ore su 24, gratuito, anonimo)" + }, + "suicidePrevention109": { + "name": "Linea di prevenzione del suicidio 109 (Corea del Sud, 24 ore su 24, gratuita)" } }, "instrumentDescription": { diff --git a/messages/ko.json b/messages/ko.json index a14f6a891..1c2173f54 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (벨기에, 24시간, 무료, 익명)" + }, + "suicidePrevention109": { + "name": "자살예방상담전화 109 (24시간, 무료)" } }, "instrumentDescription": { diff --git a/messages/nl.json b/messages/nl.json index 86a396ba9..9d4dafbdd 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (België, dag en nacht, gratis, anoniem)" + }, + "suicidePrevention109": { + "name": "Zelfmoordpreventielijn 109 (Zuid-Korea, dag en nacht, gratis)" } }, "instrumentDescription": { diff --git a/messages/pl.json b/messages/pl.json index b1d73193e..ea16d9a0d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Belgia, całą dobę, bezpłatnie, anonimowo)" + }, + "suicidePrevention109": { + "name": "Telefon zapobiegania samobójstwom 109 (Korea Południowa, całodobowo, bezpłatnie)" } }, "instrumentDescription": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 20e581383..6ff853515 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anônimo)" + }, + "suicidePrevention109": { + "name": "Linha de prevenção do suicídio 109 (Coreia do Sul, 24 horas, gratuita)" } }, "instrumentDescription": { diff --git a/messages/ru.json b/messages/ru.json index c53ea555b..e449bbec9 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -11509,6 +11509,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Бельгия, круглосуточно, бесплатно, анонимно)" + }, + "suicidePrevention109": { + "name": "Линия профилактики самоубийств 109 (Южная Корея, круглосуточно, бесплатно)" } }, "instrumentDescription": { diff --git a/src/lib/mental-health/__tests__/crisis-resources.test.ts b/src/lib/mental-health/__tests__/crisis-resources.test.ts index 0c6504d32..4b7741c4c 100644 --- a/src/lib/mental-health/__tests__/crisis-resources.test.ts +++ b/src/lib/mental-health/__tests__/crisis-resources.test.ts @@ -86,6 +86,21 @@ describe("crisisResourcesForLocale", () => { } }); + it("gives a Korean reader 119 and the 109 suicide prevention line", () => { + for (const locale of ["ko", "ko-KR", "ko_KR", "kr", "en-KR"]) { + const set = crisisResourcesForLocale(locale); + expect(set.emergencyNumber, locale).toBe("119"); + expect(set.resources, locale).toEqual([ + { id: "suicidePrevention109", contacts: ["109"] }, + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ]); + } + const { t } = getServerTranslator("ko"); + expect( + t("mentalHealth.crisisResource.suicidePrevention109.name"), + ).toContain("109"); + }); + it("resolves the country from the region subtag whatever the language", () => { const cases: Array<[string, string]> = [ ["en-BE", "zelfmoordlijn1813"], @@ -143,6 +158,7 @@ describe("crisisResourcesForLocale", () => { "en-US", "fr", "nl", + "ko", ].flatMap((l) => crisisResourcesForLocale(l).resources.map((r) => r.id)), ); for (const locale of locales) { diff --git a/src/lib/mental-health/crisis-resources.ts b/src/lib/mental-health/crisis-resources.ts index 341c57c37..c76bf58d7 100644 --- a/src/lib/mental-health/crisis-resources.ts +++ b/src/lib/mental-health/crisis-resources.ts @@ -178,6 +178,20 @@ const BE: CrisisResourceSet = { ], }; +/** + * South Korea (the region "kr", and Korean with no region, which is read + * almost only there). 119 is the emergency number for an ambulance. Since + * January 2024 the national suicide prevention line 109 has taken over the + * earlier counselling numbers and answers around the clock. + */ +const KR: CrisisResourceSet = { + emergencyNumber: "119", + resources: [ + { id: "suicidePrevention109", contacts: ["109"] }, + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ], +}; + /** * Dutch with no region signal. The app ships one Dutch bundle, `nl`, read in * the Netherlands and in Flanders alike, and the account keeps no country to @@ -205,6 +219,7 @@ const BY_REGION: Readonly> = { br: BR, ch: CH, de: DE, + kr: KR, nl: NL, pt: PT, us: US, @@ -235,7 +250,7 @@ function regionSubtag(tag: string): string | null { * bundle (see PT_BR_AMBIGUOUS), so it keeps the ambiguous set. The web stores * the app locale, which is otherwise a bare "de", "nl", "en", so it lands on * the combined sets: never assume Germany, the Netherlands or Brazil when the - * region is unknown. A bare country code ("at", "ch", "us", "br", "be") + * region is unknown. A bare country code ("at", "ch", "us", "br", "be", "kr") * still resolves to that country. */ export function crisisResourcesForLocale( @@ -248,7 +263,14 @@ export function crisisResourcesForLocale( const byRegion = BY_REGION[region]; if (byRegion) return byRegion; } - if (lc === "at" || lc === "ch" || lc === "us" || lc === "br" || lc === "be") { + if ( + lc === "at" || + lc === "ch" || + lc === "us" || + lc === "br" || + lc === "be" || + lc === "kr" + ) { return BY_REGION[lc]; } const language = lc.split("-")[0]; @@ -258,5 +280,6 @@ export function crisisResourcesForLocale( if (language === "pt") return PT_BR_AMBIGUOUS; // The bare "nl" the web stores, and any other Dutch tag: both countries. if (language === "nl") return NL_BE; + if (language === "ko") return KR; return INTERNATIONAL; } From 157809c24f361eff9b76444a5fc817dc218cc055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 18:37:01 +0100 Subject: [PATCH 04/46] fix(labs): drop a lab's calculated-value note from the unit (#1313) A read that copied "(calc)" into the unit minted a marker in "g/dL (calc)" that refused the next report's "g/dL". The note is dropped at the read and ignored in the unit comparison; any other bracket still counts. --- CHANGELOG.md | 12 ++++++++ src/lib/documents/__tests__/extract.test.ts | 30 +++++++++++++++++++ src/lib/documents/extract.ts | 16 ++++++++-- src/lib/labs/__tests__/unit-normalise.test.ts | 22 +++++++++++++- src/lib/labs/unit-normalise.ts | 24 ++++++++++++++- 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a91d90e..6bf618510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ fell through to the international set with 112, which reaches the police there, not an ambulance. +### Fixed + +- **A lab's "(calc)" note no longer becomes part of the unit.** Some + reports print "(calc)" beside a calculated value, and a document read + could copy it into the unit, so a marker was created in "g/dL (calc)" + or just "(calc)" and the next report's plain "g/dL" was refused as a + different unit. The read now drops that note (also "calculated", + "computed" and the German "berechnet"), a unit that was only the note + counts as no unit, and a marker already saved with the note accepts + the plain unit. Any other text in brackets still counts as part of + the unit. (#1313) + ## [1.47.1] — 2026-11-07 ### Added diff --git a/src/lib/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts index a637df428..af441a5ba 100644 --- a/src/lib/documents/__tests__/extract.test.ts +++ b/src/lib/documents/__tests__/extract.test.ts @@ -193,6 +193,36 @@ describe("runInboundExtraction — stated-status-only mapping", () => { expect(result.facts.map((f) => f.needsReview)).toEqual([true, false]); }); + it('drops a lab\'s "(calc)" note from the unit, so two reports land in one', async () => { + const provider = fakeProvider( + JSON.stringify({ + facts: [ + { + type: "OBSERVATION", + label: "Globulin", + value: 2.6, + unit: "g/dL (calc)", + confidence: 0.95, + sourceText: "Globulin 2.6 g/dL (calc)", + }, + { + type: "OBSERVATION", + label: "A/G Ratio", + value: 1.7, + unit: "(calc)", + confidence: 0.95, + sourceText: "A/G Ratio 1.7 (calc)", + }, + ], + }), + ); + + const result = await runInboundExtraction(oneRead(provider)); + expect( + result.facts.map((f) => (f.data as ObservationFactData).unit), + ).toEqual(["g/dL", null]); + }); + it("stores the document's real span, not the model's echo of it", async () => { // The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied // rendering of it. The stored provenance must be the document's characters. diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index 4c793ec39..d1e6bc8bc 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -58,6 +58,7 @@ import { type OcrReadSurface, } from "@/lib/documents/ocr-read"; import { isDimensionlessUnit } from "@/lib/labs/dimensionless"; +import { withoutLabUnitAnnotation } from "@/lib/labs/unit-normalise"; import { annotate } from "@/lib/logging/context"; import { INBOUND_CONFIDENCE_FLOOR, @@ -168,7 +169,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null { codeSystem: system === "LOINC" ? system : null, value: hasNumeric ? raw.value : null, valueText: hasNumeric ? null : raw.valueText, - unit: raw.unit, + unit: observationUnit(raw.unit), referenceLow: raw.referenceLow, referenceHigh: raw.referenceHigh, referenceText: raw.referenceText, @@ -189,6 +190,17 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null { return data; } +/** + * The printed unit without a lab's "(calc)" note, so the same marker read + * from two reports, one with the note and one without, lands in one unit. + * A unit that was only the note is no unit. + */ +function observationUnit(unit: string | null): string | null { + if (unit === null) return null; + const bare = withoutLabUnitAnnotation(unit); + return bare === "" ? null : bare; +} + /** A staged fact ready to persist (before it gets an id). */ export interface StagedFactInput { factType: InboundFactType; @@ -306,7 +318,7 @@ function factKey(raw: ExtractedFactRaw, readDate: string | null): string { raw.label.trim().toLowerCase(), raw.value ?? "", (raw.valueText ?? "").trim().toLowerCase(), - (raw.unit ?? "").trim().toLowerCase(), + (observationUnit(raw.unit) ?? "").toLowerCase(), (raw.referenceText ?? "").trim().toLowerCase(), (raw.dose ?? "").trim().toLowerCase(), normaliseDate(raw.effectiveDate) ?? readDate ?? "", diff --git a/src/lib/labs/__tests__/unit-normalise.test.ts b/src/lib/labs/__tests__/unit-normalise.test.ts index 5fb4e9d12..81ae7367e 100644 --- a/src/lib/labs/__tests__/unit-normalise.test.ts +++ b/src/lib/labs/__tests__/unit-normalise.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { normaliseLabUnit, sameLabUnit } from "../unit-normalise"; +import { + normaliseLabUnit, + sameLabUnit, + withoutLabUnitAnnotation, +} from "../unit-normalise"; describe("sameLabUnit — spellings of one unit", () => { it.each([ @@ -88,3 +92,19 @@ describe("normaliseLabUnit", () => { expect(normaliseLabUnit("MG/DL")).toBe("mg/dL"); }); }); + +describe("a calculated-value note in the unit", () => { + it("is not part of the unit", () => { + expect(sameLabUnit("g/dL (calc)", "g/dL")).toBe(true); + expect(sameLabUnit("mmol/l (berechnet)", "mmol/L")).toBe(true); + expect(sameLabUnit("mg/dL (Calc.)", "MG/DL")).toBe(true); + expect(withoutLabUnitAnnotation("(calc)")).toBe(""); + expect(withoutLabUnitAnnotation(" g/dL (calculated) ")).toBe("g/dL"); + }); + + it("keeps any other bracket, so it still surfaces as a mismatch", () => { + expect(sameLabUnit("mg/dL (fasting)", "mg/dL")).toBe(false); + expect(withoutLabUnitAnnotation("U/L (37 °C)")).toBe("U/L (37 °C)"); + expect(withoutLabUnitAnnotation("(calc) g/dL")).toBe("(calc) g/dL"); + }); +}); diff --git a/src/lib/labs/unit-normalise.ts b/src/lib/labs/unit-normalise.ts index b67cc3346..7b4a2eeff 100644 --- a/src/lib/labs/unit-normalise.ts +++ b/src/lib/labs/unit-normalise.ts @@ -21,9 +21,31 @@ * does not recognise is compared exactly, so an unfamiliar spelling surfaces as * a mismatch rather than being guessed at. * + * A lab's note that a value was calculated rather than measured (`g/dL + * (calc)`, `(berechnet)`) is not part of the unit and is dropped first; see + * `withoutLabUnitAnnotation`. + * * Pure and dependency-free. */ +/** + * A trailing note in brackets that says the lab calculated the value. Some + * reports print it in the unit column, and a read that copies the column + * verbatim then minted a marker in "g/dL (calc)", which refused the next + * report's plain "g/dL". Only these words are dropped, so a bracket that + * carries meaning stays and still surfaces as a mismatch. + */ +const CALCULATED_NOTE = + /\s*\((?:calc\.?|calculated|computed|berechnet|ber\.?)\)\s*$/i; + +/** + * The unit without a trailing calculated-value note, trimmed: `g/dL (calc)` + * reads `g/dL`, and a unit that was only the note reads `""`. + */ +export function withoutLabUnitAnnotation(raw: string): string { + return raw.replace(CALCULATED_NOTE, "").trim(); +} + /** Units that may be recognised when printed entirely in capitals. */ const SHOUTED_UNITS: ReadonlyMap = new Map( [ @@ -79,7 +101,7 @@ function foldLitre(text: string): string { * means the same thing, and the (trimmed) string itself otherwise. */ export function normaliseLabUnit(raw: string): string { - const spaced = foldSpacing(raw); + const spaced = foldSpacing(withoutLabUnitAnnotation(raw)); const hasLowercase = /[a-zµμ]/.test(spaced); const hasUppercase = /[A-Z]/.test(spaced); From 928ade5109e069215e2755f3959f331d2181f4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 18:57:48 +0100 Subject: [PATCH 05/46] feat(labs): let a person order the lab categories (#1349) The labs list layout gains categoryOrder, a bounded allow-list of category keys (200 entries, 64 characters each, deduplicated, malformed values dropped on read). GET /api/labs/overview reads the caller's own layout and answers categories already in that order, so every client renders them as given. Listed categories come first; keys the data does not have are ignored; a category the order does not name follows in the default order (catalog panels, then the person's own by label, then other). Other left at the end of the order stays last, behind any category added later; moved anywhere else, it stays there. Settings > Arrangement > Labs shows a category order card while categories are on. It lists the overview's categories under their localised labels, sends the whole order after every move, moves the cached overview at once and refetches it. The card says how the default order works. The OpenAPI document carries the new field. --- CHANGELOG.md | 11 ++ docs/api/openapi.yaml | 23 +++- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/nl.json | 4 +- messages/pl.json | 4 +- messages/pt-BR.json | 4 +- messages/ru.json | 4 +- src/__tests__/sharing-surface-guard.test.ts | 2 +- src/app/api/labs/overview/route.ts | 32 +++++- .../api/settings/module-list-layout/route.ts | 9 +- .../__tests__/labs-settings-groups.test.tsx | 37 ++++++ src/components/labs/labs-settings.tsx | 77 ++++++++++++- src/lib/__tests__/module-list-prefs.test.ts | 80 +++++++++++++ .../labs/__tests__/marker-category.test.ts | 108 ++++++++++++++++++ src/lib/labs/marker-category.ts | 39 ++++++- src/lib/module-list-prefs.ts | 48 +++++++- src/lib/openapi/routes/labs.ts | 2 +- src/lib/openapi/routes/settings.ts | 5 + .../__tests__/module-list-layout.test.ts | 42 +++++++ src/lib/settings/module-list-layout.ts | 31 ++++- src/lib/validations/settings-prefs.ts | 5 + .../labs-overview-long-history.test.ts | 47 ++++++++ .../integration/settings-prefs-routes.test.ts | 56 +++++++++ 28 files changed, 660 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf618510..970b17acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,17 @@ prevention line 109, which answers around the clock. Before, Korean fell through to the international set with 112, which reaches the police there, not an ambulance. +- **Set the order of the lab categories.** With the labs list grouped + by category you could reorder the markers but not the categories, and + nothing said how they were ordered. Settings > Arrangement > Labs now + has a "Category order" card while categories are on: move a category + with the handle or the arrows, and the card explains the default + (built-in categories in a fixed order, your own panels alphabetically, + Other last). A category that turns up later joins the end of your + order, and Other stays last if you left it there. The order is saved + with your account and applied by the server, so the app shows the same + order. The labs list layout accepts `categoryOrder`, and + `GET /api/labs/overview` answers `categories` in that order. (#1349) ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3f21603d9..21f8a4772 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34362,6 +34362,13 @@ components: - expanded - flagged - collapsed + categoryOrder: + maxItems: 200 + type: array + items: + type: string + minLength: 1 + maxLength: 64 required: - version - list @@ -75520,6 +75527,15 @@ components: - collapsed description: 'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.' + categoryOrder: + type: array + items: + type: string + description: The person's order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The + server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing + from the live data are ignored; a category missing from the list follows the listed ones in the default + order, and "other" left at the end of the list stays last. Default empty (the default order). The other + lists ignore it. saved: type: boolean description: True when the server holds a choice for this list; false means every field is a default. @@ -75529,6 +75545,7 @@ components: - sortDir - groupBy - groupsOpen + - categoryOrder - saved additionalProperties: false ModuleListLayoutSavedEnvelope: @@ -77121,8 +77138,10 @@ components: type: array items: $ref: "#/components/schemas/LabOverviewCategory" - description: "The list's categories in display order: catalog panels in catalog order, then the person's own panels by - label, then `other`." + description: "The list's categories in display order, already arranged for the caller: the default order is catalog + panels in catalog order, then the person's own panels by label, then `other`, and the caller's + `categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this + order." grouped: type: boolean description: "Whether grouping adds anything: true when there are two categories or more and at least one is not diff --git a/messages/de.json b/messages/de.json index 578b94d21..32e236fd1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10596,7 +10596,9 @@ "openExpanded": "Alle", "openFlagged": "Nur außerhalb des Bereichs", "openCollapsed": "Keine", - "orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie." + "orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie.", + "orderHeading": "Reihenfolge der Kategorien", + "orderDefault": "Bis du eine verschiebst, stehen eingebaute Kategorien in fester Folge, deine eigenen alphabetisch und {other} zuletzt." }, "sourceRange": { "fromReport": "Befundbereich {range}", diff --git a/messages/en.json b/messages/en.json index 9d90b4bf4..dd1f767d9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10596,7 +10596,9 @@ "openExpanded": "All", "openFlagged": "Outside range only", "openCollapsed": "None", - "orderWithinGroups": "The order applies within each category." + "orderWithinGroups": "The order applies within each category.", + "orderHeading": "Category order", + "orderDefault": "Until you move one, built-in categories keep a fixed order, your own follow alphabetically and {other} comes last." }, "sourceRange": { "fromReport": "Report range {range}", diff --git a/messages/es.json b/messages/es.json index 9dc9cf7c6..e65f3d6bf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10596,7 +10596,9 @@ "openExpanded": "Todas", "openFlagged": "Solo fuera de rango", "openCollapsed": "Ninguna", - "orderWithinGroups": "El orden se aplica dentro de cada categoría." + "orderWithinGroups": "El orden se aplica dentro de cada categoría.", + "orderHeading": "Orden de las categorías", + "orderDefault": "Hasta que muevas una, las categorías integradas siguen un orden fijo, las tuyas van por orden alfabético y {other} al final." }, "sourceRange": { "fromReport": "Rango del informe {range}", diff --git a/messages/fr.json b/messages/fr.json index ebd9846e5..e223f107e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10596,7 +10596,9 @@ "openExpanded": "Toutes", "openFlagged": "Hors norme seulement", "openCollapsed": "Aucune", - "orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie." + "orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie.", + "orderHeading": "Ordre des catégories", + "orderDefault": "Tant que vous n’en déplacez aucune, les catégories intégrées suivent un ordre fixe, les vôtres l’ordre alphabétique et {other} vient en dernier." }, "sourceRange": { "fromReport": "Plage du compte rendu {range}", diff --git a/messages/it.json b/messages/it.json index f77f140ee..1344a1f42 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10596,7 +10596,9 @@ "openExpanded": "Tutte", "openFlagged": "Solo fuori norma", "openCollapsed": "Nessuna", - "orderWithinGroups": "L’ordine vale all’interno di ogni categoria." + "orderWithinGroups": "L’ordine vale all’interno di ogni categoria.", + "orderHeading": "Ordine delle categorie", + "orderDefault": "Finché non ne sposti una, le categorie predefinite seguono un ordine fisso, le tue l’ordine alfabetico e {other} viene per ultima." }, "sourceRange": { "fromReport": "Intervallo del referto {range}", diff --git a/messages/ko.json b/messages/ko.json index 1c2173f54..3f8be41c8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10596,7 +10596,9 @@ "openExpanded": "모두", "openFlagged": "범위 밖만", "openCollapsed": "없음", - "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요." + "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요.", + "orderHeading": "카테고리 순서", + "orderDefault": "직접 옮기기 전에는 기본 카테고리가 정해진 순서로, 직접 만든 카테고리가 가나다순으로 이어지고 {other}가 마지막에 와요." }, "sourceRange": { "fromReport": "결과지 범위 {range}", diff --git a/messages/nl.json b/messages/nl.json index 9d4dafbdd..6927f1ddf 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10596,7 +10596,9 @@ "openExpanded": "Alle", "openFlagged": "Alleen buiten bereik", "openCollapsed": "Geen", - "orderWithinGroups": "De volgorde geldt binnen elke categorie." + "orderWithinGroups": "De volgorde geldt binnen elke categorie.", + "orderHeading": "Volgorde van de categorieën", + "orderDefault": "Tot je er een verplaatst, staan de ingebouwde categorieën in een vaste volgorde, je eigen alfabetisch daarna en {other} als laatste." }, "sourceRange": { "fromReport": "Bereik volgens rapport: {range}", diff --git a/messages/pl.json b/messages/pl.json index ea16d9a0d..eb32afe8a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10596,7 +10596,9 @@ "openExpanded": "Wszystkie", "openFlagged": "Tylko poza normą", "openCollapsed": "Żadne", - "orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii." + "orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii.", + "orderHeading": "Kolejność kategorii", + "orderDefault": "Dopóki żadnej nie przesuniesz, wbudowane kategorie mają stałą kolejność, twoje własne idą alfabetycznie, a {other} na końcu." }, "sourceRange": { "fromReport": "Zakres z wyniku {range}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6ff853515..c14042ca8 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10596,7 +10596,9 @@ "openExpanded": "Todas", "openFlagged": "Só fora da faixa", "openCollapsed": "Nenhuma", - "orderWithinGroups": "A ordem vale dentro de cada categoria." + "orderWithinGroups": "A ordem vale dentro de cada categoria.", + "orderHeading": "Ordem das categorias", + "orderDefault": "Até você mover uma, as categorias integradas seguem uma ordem fixa, as suas vêm em ordem alfabética e {other} fica por último." }, "sourceRange": { "fromReport": "Faixa do laudo {range}", diff --git a/messages/ru.json b/messages/ru.json index e449bbec9..f39bdda15 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10596,7 +10596,9 @@ "openExpanded": "Все", "openFlagged": "Только вне диапазона", "openCollapsed": "Ни одной", - "orderWithinGroups": "Порядок действует внутри каждой категории." + "orderWithinGroups": "Порядок действует внутри каждой категории.", + "orderHeading": "Порядок категорий", + "orderDefault": "Пока вы ничего не переместили, встроенные категории идут в постоянном порядке, ваши собственные по алфавиту, а {other} в конце." }, "sourceRange": { "fromReport": "Диапазон из отчёта {range}", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index f899a5e6b..4c5c12a33 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/labs/overview/route.ts": { domain: "labs", - why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.", + why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.", }, "app/api/biomarkers/route.ts": { domain: "labs", diff --git a/src/app/api/labs/overview/route.ts b/src/app/api/labs/overview/route.ts index 5b4d4ac2a..a84fd5e39 100644 --- a/src/app/api/labs/overview/route.ts +++ b/src/app/api/labs/overview/route.ts @@ -2,10 +2,12 @@ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { categoriseOverview } from "@/lib/labs/marker-category"; import { loadLabOverview } from "@/lib/labs/overview"; import { annotate } from "@/lib/logging/context"; +import { resolveModuleListLayout } from "@/lib/settings/module-list-layout"; import { labOverviewQuerySchema } from "@/lib/validations/labs"; /** @@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs"; * `categories` and `grouped` are the list's categories, resolved here once * (`src/lib/labs/marker-category.ts`): order, label, members, counts and a * combined status. Labels follow the viewer's locale, so on a managed record - * the caller's own language wins, not the record owner's. + * the caller's own language wins, not the record owner's. The order is the + * default one with the caller's `categoryOrder` (the labs list layout, + * `PUT /api/settings/module-list-layout`) applied, for the same reason: it is + * the viewer's presentation, so a person acting for another record sees the + * categories in their own order. A client renders the order as given. */ export const GET = apiHandler(async (request: NextRequest) => { const { user, actor } = await requireRecordAuth("read", "labs"); @@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const markers = await loadLabOverview(user.id); + const [markers, layoutRow] = await Promise.all([ + loadLabOverview(user.id), + prisma.user.findUnique({ + where: { id: actor.id }, + select: { moduleListLayoutJson: true }, + }), + ]); + const { categoryOrder } = resolveModuleListLayout( + layoutRow?.moduleListLayoutJson, + ).lists.labs; const locale = await resolveServerLocale({ request, userLocale: actor.locale ?? null, }); - const { categories, grouped } = categoriseOverview(markers, locale); + const { categories, grouped } = categoriseOverview( + markers, + locale, + categoryOrder, + ); annotate({ action: { name: "labs.overview" }, - meta: { markers: markers.length, categories: categories.length, grouped }, + meta: { + markers: markers.length, + categories: categories.length, + grouped, + categoryOrderLength: categoryOrder.length, + }, }); return apiSuccess({ markers, categories, grouped }); }); diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts index cbf362c86..8cd037c90 100644 --- a/src/app/api/settings/module-list-layout/route.ts +++ b/src/app/api/settings/module-list-layout/route.ts @@ -58,14 +58,15 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: Request) => { const { user } = await requireActorAuth(); - // 500 ids of up to 64 characters plus the envelope. + // 500 ids plus 200 category keys of up to 64 characters, plus the envelope. const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, { - maxBytes: 48 * 1024, + maxBytes: 64 * 1024, action: "settings.module-list-layout.validation-failed", }); if (!parsed.ok) return parsed.error; - const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data; + const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } = + parsed.data; // Preserve-when-absent: one stored read fills whichever field the body // left out, and every other list stays as stored. Only fields a person has // chosen are written, so a field never picked keeps following the default. @@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => { ...(sortDir ? { sortDir } : {}), ...(groupBy ? { groupBy } : {}), ...(groupsOpen ? { groupsOpen } : {}), + ...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}), }; const next = { ...stored, [list]: nextChoices }; await tx.user.update({ @@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => { groupBy: layout.lists[list].groupBy, groupsOpen: layout.lists[list].groupsOpen, orderLength: layout.lists[list].order.length, + categoryOrderLength: layout.lists[list].categoryOrder.length, }, }); return apiSuccess(layout); diff --git a/src/components/labs/__tests__/labs-settings-groups.test.tsx b/src/components/labs/__tests__/labs-settings-groups.test.tsx index b08f243e8..8fd32feb0 100644 --- a/src/components/labs/__tests__/labs-settings-groups.test.tsx +++ b/src/components/labs/__tests__/labs-settings-groups.test.tsx @@ -28,6 +28,21 @@ function render(prefs: Partial) { biomarkers: [], customPanels: [], }); + // The server's answer, already in the person's order. + queryClient.setQueryData(queryKeys.labResultsOverview("en"), { + markers: [], + grouped: true, + categories: [ + { key: "thyroid", label: "Thyroid" }, + { key: "other", label: "Other" }, + { key: "lipids", label: "Lipids" }, + ].map((category) => ({ + ...category, + markerKeys: [], + counts: { inRange: 0, below: 0, above: 0, unknown: 0 }, + status: "unknown", + })), + }); return renderToStaticMarkup( @@ -60,4 +75,26 @@ describe(" categories", () => { "The order applies within each category.", ); }); + + it("offers the category order only while categories are on", () => { + expect(render({})).not.toContain('id="labs-category-order"'); + expect(render({ groupBy: "category" })).toContain( + 'id="labs-category-order"', + ); + }); + + it("lists the categories as the server ordered them, under their labels", () => { + const html = render({ groupBy: "category" }); + const card = html.slice(html.indexOf('id="labs-category-order"')); + const at = (label: string) => card.indexOf(`title="${label}"`); + expect(at("Thyroid")).toBeGreaterThan(-1); + expect(at("Thyroid")).toBeLessThan(at("Other")); + expect(at("Other")).toBeLessThan(at("Lipids")); + }); + + it("says how the categories are ordered until the person moves one", () => { + expect(render({ groupBy: "category" })).toContain( + "Until you move one, built-in categories keep a fixed order, your own follow alphabetically and Other comes last.", + ); + }); }); diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index f8404ec92..a5723137f 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -5,8 +5,8 @@ * * `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view * (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual - * order of the biomarkers, and whether the list shows its categories and - * which of them start open. Persisted per account through + * order of the biomarkers, and whether the list shows its categories, which + * of them start open and their order. Persisted per account through * `useModuleListPrefs("labs")`. * * `LabsModuleSettings` — Settings > Modules > Labs: the on-device text @@ -19,6 +19,7 @@ import { ArrowDownUp, FlaskConical, FolderTree, + Layers, LayoutGrid, ListOrdered, ScanText, @@ -27,6 +28,7 @@ import { import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Segmented } from "@/components/ui/segmented"; @@ -49,14 +51,25 @@ import { } from "@/lib/module-list-prefs"; import { BiomarkerManager } from "./biomarker-manager"; -import type { BiomarkerDto, BiomarkerListResponse } from "./types"; +import type { + BiomarkerDto, + BiomarkerListResponse, + LabOverviewResponse, +} from "./types"; import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract"; import { labUnitLabel } from "@/lib/labs/dimensionless"; export function LabsSettings() { const { t } = useTranslations(); - const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } = - useModuleListPrefs("labs"); + const { + prefs, + setView, + setOrder, + setSortDir, + setGroupBy, + setGroupsOpen, + setCategoryOrder, + } = useModuleListPrefs("labs"); const { data, isLoading } = useQuery({ queryKey: queryKeys.biomarkers(), @@ -120,6 +133,10 @@ export function LabsSettings() { onGroupsOpen={setGroupsOpen} /> + {prefs.groupBy === "category" ? ( + + ) : null} + {manual ? ( void; +}) { + const { t, locale } = useTranslations(); + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: queryKeys.labResultsOverview(locale), + queryFn: () => apiGet("/api/labs/overview"), + }); + const items: ReorderItem[] = (data?.categories ?? []).map((category) => ({ + id: category.key, + name: category.label, + })); + + return ( + + + {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) : isError ? ( + void refetch()} + /> + ) : ( + + )} +
+ ); +} + export function LabsModuleSettings() { const { t } = useTranslations(); // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts index 896e3d4a6..301dd5be0 100644 --- a/src/lib/__tests__/module-list-prefs.test.ts +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -68,6 +68,7 @@ describe("parseModuleListPrefs", () => { order: ["a", "b"], groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], }); }); @@ -204,6 +205,85 @@ describe("runSetModuleListPrefs", () => { }); }); +describe("runSetModuleListPrefs category order", () => { + const t = (key: string) => key; + const overview = (keys: string[]) => ({ + markers: [], + grouped: true, + categories: keys.map((key) => ({ key, label: key })), + }); + + it("moves the cached overview's categories at once and refetches it", async () => { + const queryClient = new QueryClient(); + const cell = queryKeys.labResultsOverview("en"); + queryClient.setQueryData(cell, overview(["lipids", "thyroid", "other"])); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + let cachedDuringWrite: unknown; + vi.mocked(apiPut).mockImplementation(async () => { + cachedDuringWrite = queryClient.getQueryData(cell); + return resolveModuleListLayout({ + labs: { categoryOrder: ["thyroid", "other", "lipids"] }, + }); + }); + + await runSetModuleListPrefs({ + list: "labs", + patch: { categoryOrder: ["thyroid", "other", "lipids"] }, + queryClient, + t, + }); + + expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", { + version: 1, + list: "labs", + categoryOrder: ["thyroid", "other", "lipids"], + }); + expect( + (cachedDuringWrite as ReturnType).categories.map( + (c) => c.key, + ), + ).toEqual(["thyroid", "other", "lipids"]); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.labResultsOverview(), + }); + // An order move is its own feedback, like the marker order. + expect(toastWrittenOutcome).not.toHaveBeenCalled(); + }); + + it("refetches the overview after a failed write too, so the server order returns", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overview(["lipids", "thyroid"]), + ); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + vi.mocked(apiPut).mockRejectedValue(new Error("offline")); + await runSetModuleListPrefs({ + list: "labs", + patch: { categoryOrder: ["thyroid", "lipids"] }, + queryClient, + t, + }); + expect(showErrorToast).toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.labResultsOverview(), + }); + }); + + it("leaves the overview alone for any other field", async () => { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + vi.mocked(apiPut).mockResolvedValue(resolveModuleListLayout(null)); + await runSetModuleListPrefs({ + list: "labs", + patch: { order: ["m1"] }, + queryClient, + t, + }); + expect(invalidate).not.toHaveBeenCalled(); + }); +}); + describe("applyOrder", () => { const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; const idOf = (i: { id: string }) => i.id; diff --git a/src/lib/labs/__tests__/marker-category.test.ts b/src/lib/labs/__tests__/marker-category.test.ts index 56ff193cc..045857895 100644 --- a/src/lib/labs/__tests__/marker-category.test.ts +++ b/src/lib/labs/__tests__/marker-category.test.ts @@ -6,6 +6,7 @@ import type { ReferenceRangeStatus } from "@/lib/labs/reference-range"; import { ALIAS_PANEL, BIOMARKER_PANELS } from "../biomarker-catalog"; import { + applyCategoryOrder, categoriseOverview, customCategoryLabel, customPanelSuggestions, @@ -222,3 +223,110 @@ describe("categoriseOverview", () => { ).toBe(true); }); }); + +describe("applyCategoryOrder", () => { + const cats = (...keys: string[]) => keys.map((key) => ({ key })); + const keysOf = (list: { key: string }[]) => list.map((c) => c.key); + + it("keeps the default order without a saved order", () => { + expect( + keysOf(applyCategoryOrder(cats("lipids", "thyroid", "other"), [])), + ).toEqual(["lipids", "thyroid", "other"]); + }); + + it("puts the listed categories first, in the listed order", () => { + expect( + keysOf( + applyCategoryOrder(cats("lipids", "thyroid", "liver", "other"), [ + "liver", + "lipids", + "thyroid", + "other", + ]), + ), + ).toEqual(["liver", "lipids", "thyroid", "other"]); + }); + + it("ignores keys the data does not have", () => { + expect( + keysOf( + applyCategoryOrder(cats("lipids", "thyroid"), [ + "custom:gone", + "thyroid", + "iron", + "lipids", + ]), + ), + ).toEqual(["thyroid", "lipids"]); + }); + + it("adds a category the order does not name after the listed ones, in the default order", () => { + expect( + keysOf( + applyCategoryOrder(cats("lipids", "glucose", "thyroid", "liver"), [ + "thyroid", + "lipids", + ]), + ), + ).toEqual(["thyroid", "lipids", "glucose", "liver"]); + }); + + it("keeps other last when the person left it at the end", () => { + expect( + keysOf( + applyCategoryOrder(cats("lipids", "iron", "custom:abc", "other"), [ + "lipids", + "other", + ]), + ), + ).toEqual(["lipids", "iron", "custom:abc", "other"]); + }); + + it("keeps other where the person moved it", () => { + expect( + keysOf( + applyCategoryOrder(cats("lipids", "iron", "thyroid", "other"), [ + "other", + "lipids", + "thyroid", + ]), + ), + ).toEqual(["other", "lipids", "thyroid", "iron"]); + }); + + it("judges other's place among the present categories only", () => { + // "thyroid" is gone from the data, so "other" ends the listed block. + expect( + keysOf( + applyCategoryOrder(cats("lipids", "iron", "other"), [ + "lipids", + "other", + "thyroid", + ]), + ), + ).toEqual(["lipids", "iron", "other"]); + }); +}); + +describe("categoriseOverview with a saved category order", () => { + it("answers the categories in the person's order", () => { + const { categories, grouped } = categoriseOverview( + [ + marker("bm:1", "Mystery", null), + marker("bm:2", "TSH", null), + marker("bm:3", "LDL", null), + marker("bm:4", "Cortisol", "Hormones"), + ], + "en", + ["thyroid", "other", "lipids"], + ); + expect(grouped).toBe(true); + // Hormones is not in the order: it follows the listed categories. + expect(categories.map((c) => c.label)).toEqual([ + "Thyroid", + "Other", + "Lipids", + "Hormones", + ]); + }); +}); diff --git a/src/lib/labs/marker-category.ts b/src/lib/labs/marker-category.ts index 238f27ae1..b3232681f 100644 --- a/src/lib/labs/marker-category.ts +++ b/src/lib/labs/marker-category.ts @@ -197,14 +197,47 @@ export interface CategorisableMarker { } /** - * Every category of an overview, in display order (catalog panels in catalog - * order, then the person's own by label, then "other"), and whether the list + * The person's category order (`categoryOrder` in the labs list layout) + * over categories already in the default order. + * + * Listed keys come first, in the listed order; a key the data does not have + * is skipped. A category the list does not name (a panel added since the + * person arranged the list) follows the listed ones in the default order. + * "other" is the one exception: left at the end of the listed categories it + * stays last, after any category added later, because a catch-all belongs + * at the bottom unless the person moved it up. An empty order is the + * default order. + */ +export function applyCategoryOrder( + categories: readonly T[], + order: readonly string[], +): T[] { + if (order.length === 0) return [...categories]; + const present = new Set(categories.map((category) => category.key)); + const listed = order.filter((key) => present.has(key)); + const rank = new Map(listed.map((key, index) => [key, index])); + const ordered = categories + .filter((category) => rank.has(category.key)) + .sort((a, b) => rank.get(a.key)! - rank.get(b.key)!); + const rest = categories.filter((category) => !rank.has(category.key)); + const otherLast = ordered.at(-1)?.key === OTHER_CATEGORY; + if (!otherLast || rest.length === 0) return [...ordered, ...rest]; + return [...ordered.slice(0, -1), ...rest, ordered.at(-1)!]; +} + +/** + * Every category of an overview, in display order, and whether the list * groups at all: it does when there are two categories or more and at least * one is not "other". One category, or only "other", stays a flat list. + * + * The default order is catalog panels in catalog order (`BIOMARKER_PANELS`), + * then the person's own by label, then "other"; `categoryOrder`, the + * person's own arrangement, is applied over it (`applyCategoryOrder`). */ export function categoriseOverview( markers: readonly CategorisableMarker[], locale: Locale, + categoryOrder: readonly string[] = [], ): { categories: LabOverviewCategory[]; grouped: boolean } { const { t } = getServerTranslator(locale); const buckets = new Map< @@ -270,5 +303,5 @@ export function categoriseOverview( const grouped = categories.length >= 2 && categories.some((category) => category.key !== OTHER_CATEGORY); - return { categories, grouped }; + return { categories: applyCategoryOrder(categories, categoryOrder), grouped }; } diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index 10e279b01..1b300bca7 100644 --- a/src/lib/module-list-prefs.ts +++ b/src/lib/module-list-prefs.ts @@ -2,7 +2,8 @@ /** * Per-list presentation for the labs, illness and checkups lists: card or - * list view, a manual order, and (labs only) the sort and the categories. + * list view, a manual order, and (labs only) the sort, the categories and + * their order. * * The choices live on the server (`GET/PUT /api/settings/module-list-layout`, * `User.moduleListLayoutJson`) so they follow a person to every device. Until @@ -198,6 +199,11 @@ type Translator = ( * Apply one field optimistically (the control has to feel instant), then * persist it. On failure the cache rolls back and a toast says so; a view or * sort change confirms with a toast, an order change is its own feedback. + * + * The category order is applied by the server (`GET /api/labs/overview` + * answers `categories` already arranged), so a change to it moves the + * cached overview's categories to the full order the editor sent, at once, + * and then refetches the overview, whose answer is the one that stays. */ export async function runSetModuleListPrefs(deps: { list: ModuleListKey; @@ -210,6 +216,25 @@ export async function runSetModuleListPrefs(deps: { if (queryClient.isFetching({ queryKey: key }) > 0) { await queryClient.cancelQueries({ queryKey: key }); } + const overviewKey = queryKeys.labResultsOverview(); + const categoryOrder = patch.categoryOrder; + if (categoryOrder !== undefined) { + await queryClient.cancelQueries({ queryKey: overviewKey }); + queryClient.setQueriesData<{ categories?: { key: string }[] }>( + { queryKey: overviewKey }, + (cached) => + cached?.categories + ? { + ...cached, + categories: applyOrder( + cached.categories, + categoryOrder, + (category) => category.key, + ), + } + : cached, + ); + } const previous = queryClient.getQueryData(key); const base = previous ?? resolveModuleListLayout(null); queryClient.setQueryData(key, { @@ -237,6 +262,10 @@ export async function runSetModuleListPrefs(deps: { } catch (failure) { queryClient.setQueryData(key, previous); showErrorToast(t("settings.savingError"), { cause: failure }); + } finally { + if (categoryOrder !== undefined) { + void queryClient.invalidateQueries({ queryKey: overviewKey }); + } } } @@ -251,6 +280,7 @@ export function useModuleListPrefs(list: ModuleListKey): { setSortDir: (sortDir: ModuleSortDir) => void; setGroupBy: (groupBy: ModuleGroupBy) => void; setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void; + setCategoryOrder: (categoryOrder: string[]) => void; } { const queryClient = useQueryClient(); const { t } = useTranslations(); @@ -278,6 +308,7 @@ export function useModuleListPrefs(list: ModuleListKey): { sortDir: entry.sortDir, groupBy: entry.groupBy, groupsOpen: entry.groupsOpen, + categoryOrder: entry.categoryOrder, }; } const defaults = moduleListDefaults(list); @@ -310,5 +341,18 @@ export function useModuleListPrefs(list: ModuleListKey): { [write], ); - return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen }; + const setCategoryOrder = useCallback( + (categoryOrder: string[]) => write({ categoryOrder }), + [write], + ); + + return { + prefs, + setView, + setOrder, + setSortDir, + setGroupBy, + setGroupsOpen, + setCategoryOrder, + }; } diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 42a7849ca..896ee2afe 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -171,7 +171,7 @@ export const labOverviewResponse = z markers: z.array(labOverviewMarker), categories: z.array(labOverviewCategory).meta({ description: - "The list's categories in display order: catalog panels in catalog order, then the person's own panels by label, then `other`.", + "The list's categories in display order, already arranged for the caller: the default order is catalog panels in catalog order, then the person's own panels by label, then `other`, and the caller's `categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this order.", }), grouped: z.boolean().meta({ description: diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 897c75a69..133dca2bf 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -59,6 +59,11 @@ const moduleListPrefsEntry = z .describe( 'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.', ), + categoryOrder: z + .array(z.string()) + .describe( + 'The person\'s order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing from the live data are ignored; a category missing from the list follows the listed ones in the default order, and "other" left at the end of the list stays last. Default empty (the default order). The other lists ignore it.', + ), saved: z .boolean() .describe( diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts index afc43656c..b5081cdef 100644 --- a/src/lib/settings/__tests__/module-list-layout.test.ts +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + MODULE_LIST_CATEGORY_ORDER_MAX, MODULE_LIST_ORDER_MAX, normaliseModuleListOrder, resolveModuleListLayout, @@ -16,6 +17,7 @@ describe("resolveModuleListLayout", () => { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: false, }); expect(layout.lists.illness.view).toBe("cards"); @@ -34,6 +36,7 @@ describe("resolveModuleListLayout", () => { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: true, }); expect(layout.lists.labs.saved).toBe(false); @@ -50,6 +53,7 @@ describe("resolveModuleListLayout", () => { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: false, }); expect(layout.lists.vorsorge.saved).toBe(false); @@ -70,11 +74,49 @@ describe("category choices", () => { expect(layout.lists.illness).toMatchObject({ groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: false, }); }); }); +describe("category order", () => { + it("keeps a stored category order, deduplicated, and marks the list saved", () => { + const layout = resolveModuleListLayout({ + labs: { categoryOrder: ["thyroid", "lipids", "thyroid", 7, "other"] }, + }); + expect(layout.lists.labs).toMatchObject({ + categoryOrder: ["thyroid", "lipids", "other"], + saved: true, + }); + }); + + it("drops a malformed category order and leaves the default", () => { + const layout = resolveModuleListLayout({ + labs: { categoryOrder: "lipids" }, + }); + expect(layout.lists.labs.categoryOrder).toEqual([]); + expect(layout.lists.labs.saved).toBe(false); + }); + + it("caps the category order on its own limit", () => { + const keys = Array.from( + { length: MODULE_LIST_CATEGORY_ORDER_MAX + 5 }, + (_, i) => `custom:${i}`, + ); + const layout = resolveModuleListLayout({ labs: { categoryOrder: keys } }); + expect(layout.lists.labs.categoryOrder).toHaveLength( + MODULE_LIST_CATEGORY_ORDER_MAX, + ); + }); + + it("hands every list its own copy of the default", () => { + const layout = resolveModuleListLayout(null); + layout.lists.labs.categoryOrder.push("lipids"); + expect(resolveModuleListLayout(null).lists.labs.categoryOrder).toEqual([]); + }); +}); + describe("normaliseModuleListOrder", () => { it("keeps strings, drops duplicates, empties and over-long ids", () => { expect( diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts index aa7cf8881..f57bc607d 100644 --- a/src/lib/settings/module-list-layout.ts +++ b/src/lib/settings/module-list-layout.ts @@ -14,6 +14,11 @@ * the list under its server-resolved categories (`"category"`) or flat * (`"none"`, the default); `groupsOpen` says which categories start open: * all of them, only those with a value outside its range, or none. + * - `categoryOrder`: only the labs list reads it, and only on the server: a + * category-key allow-list `GET /api/labs/overview` applies before it + * answers `categories` (`applyCategoryOrder` in + * `src/lib/labs/marker-category.ts`), so every client receives the + * categories already in the person's order. Same tolerance as `order`. * * Null / missing column = the defaults below. The GET never lazy-writes, and * the PUT stores only the fields a person has actually chosen, so a default @@ -51,6 +56,11 @@ export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number]; export const MODULE_LIST_ORDER_MAX = 500; /** Longest id an order entry may carry (cuid and uuid fit comfortably). */ export const MODULE_LIST_ID_MAX = 64; +/** + * Longest category order a list keeps. Ten catalog panels plus "other" plus + * the person's own panels; a longer one is cut, never refused. + */ +export const MODULE_LIST_CATEGORY_ORDER_MAX = 200; export interface ModuleListPrefs { view: ModuleListView; @@ -58,6 +68,7 @@ export interface ModuleListPrefs { sortDir: ModuleSortDir; groupBy: ModuleGroupBy; groupsOpen: ModuleGroupsOpen; + categoryOrder: string[]; } export interface ModuleListPrefsEntry extends ModuleListPrefs { @@ -76,6 +87,7 @@ const BASE_DEFAULTS: ModuleListPrefs = { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], }; /** Labs opens on the compact list, the dense reading table (#40). */ @@ -87,7 +99,11 @@ const LIST_DEFAULTS: Record = { export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs { const defaults = LIST_DEFAULTS[list]; - return { ...defaults, order: [...defaults.order] }; + return { + ...defaults, + order: [...defaults.order], + categoryOrder: [...defaults.categoryOrder], + }; } export function isModuleListView(value: unknown): value is ModuleListView { @@ -119,7 +135,10 @@ export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen { } /** Keep string ids of a sane length, first occurrence wins, capped. */ -export function normaliseModuleListOrder(raw: unknown): string[] | null { +export function normaliseModuleListOrder( + raw: unknown, + max: number = MODULE_LIST_ORDER_MAX, +): string[] | null { if (!Array.isArray(raw)) return null; const seen = new Set(); const out: string[] = []; @@ -129,7 +148,7 @@ export function normaliseModuleListOrder(raw: unknown): string[] | null { if (seen.has(value)) continue; seen.add(value); out.push(value); - if (out.length >= MODULE_LIST_ORDER_MAX) break; + if (out.length >= max) break; } return out; } @@ -148,8 +167,13 @@ export function storedModuleListChoices( sortDir?: unknown; groupBy?: unknown; groupsOpen?: unknown; + categoryOrder?: unknown; }; const order = normaliseModuleListOrder(blob.order); + const categoryOrder = normaliseModuleListOrder( + blob.categoryOrder, + MODULE_LIST_CATEGORY_ORDER_MAX, + ); return { ...(isModuleListView(blob.view) ? { view: blob.view } : {}), ...(order !== null ? { order } : {}), @@ -158,6 +182,7 @@ export function storedModuleListChoices( ...(isModuleGroupsOpen(blob.groupsOpen) ? { groupsOpen: blob.groupsOpen } : {}), + ...(categoryOrder !== null ? { categoryOrder } : {}), }; } diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts index 70e5c231a..019143641 100644 --- a/src/lib/validations/settings-prefs.ts +++ b/src/lib/validations/settings-prefs.ts @@ -9,6 +9,7 @@ import { z } from "zod/v4"; import { MODULE_GROUPS_OPEN, MODULE_GROUP_BYS, + MODULE_LIST_CATEGORY_ORDER_MAX, MODULE_LIST_ID_MAX, MODULE_LIST_KEYS, MODULE_LIST_ORDER_MAX, @@ -28,5 +29,9 @@ export const moduleListLayoutPutSchema = z sortDir: z.enum(MODULE_SORT_DIRS).optional(), groupBy: z.enum(MODULE_GROUP_BYS).optional(), groupsOpen: z.enum(MODULE_GROUPS_OPEN).optional(), + categoryOrder: z + .array(z.string().min(1).max(MODULE_LIST_ID_MAX)) + .max(MODULE_LIST_CATEGORY_ORDER_MAX) + .optional(), }) .strict(); diff --git a/tests/integration/labs-overview-long-history.test.ts b/tests/integration/labs-overview-long-history.test.ts index be3ddffc4..0e935c971 100644 --- a/tests/integration/labs-overview-long-history.test.ts +++ b/tests/integration/labs-overview-long-history.test.ts @@ -401,6 +401,53 @@ describe("GET /api/labs/overview categories", () => { ]); }); + it("answers the categories in the caller's saved order", async () => { + await seedCategories(); + cookieJar.set("healthlog-locale", "en"); + const { PUT } = await import("@/app/api/settings/module-list-layout/route"); + const saved = await PUT( + new Request("http://localhost/api/settings/module-list-layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + // Lipids is left out (it follows the listed ones), "iron" has no + // values here (ignored), and "other" sits first. + body: JSON.stringify({ + version: 1, + list: "labs", + categoryOrder: ["other", "iron", "thyroid"], + }), + }), + ); + expect(saved.status).toBe(200); + const { categories, grouped } = ( + (await (await getOverview()).json()) as CategoryBody + ).data; + expect(grouped).toBe(true); + expect(categories.map((c) => c.label)).toEqual([ + "Other", + "Thyroid", + "Lipids", + "trace ELEMENTS", + ]); + + // Left at the end, "other" stays last behind the categories not named. + await PUT( + new Request("http://localhost/api/settings/module-list-layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 1, + list: "labs", + categoryOrder: ["thyroid", "other"], + }), + }), + ); + const again = ( + (await (await getOverview()).json()) as CategoryBody + ).data.categories.map((c) => c.label); + expect(again).toEqual(["Thyroid", "Lipids", "trace ELEMENTS", "Other"]); + }); + it("stays flat when grouping adds nothing", async () => { const prisma = getPrismaClient(); await prisma.labResult.create({ diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts index eda334939..f74a61657 100644 --- a/tests/integration/settings-prefs-routes.test.ts +++ b/tests/integration/settings-prefs-routes.test.ts @@ -126,6 +126,7 @@ describe("/api/settings/module-list-layout — integration", () => { sortDir: "manual", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: true, }); expect(body.data.lists.illness).toMatchObject({ @@ -190,6 +191,61 @@ describe("/api/settings/module-list-layout — integration", () => { expect(refused.status).toBe(422); }); + it("stores the category order deduplicated and keeps it through other writes", async () => { + const { userId } = await seedUser(); + const { GET, PUT } = + await import("@/app/api/settings/module-list-layout/route"); + const put = await (PUT as Handler)( + json("PUT", URL, { + version: 1, + list: "labs", + categoryOrder: ["thyroid", "other", "thyroid", "lipids"], + }), + ); + expect(put.status).toBe(200); + await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "labs", groupBy: "category" }), + ); + const res = await (GET as Handler)(new Request(URL)); + const body = (await res.json()) as { + data: { lists: Record> }; + }; + expect(body.data.lists.labs).toMatchObject({ + categoryOrder: ["thyroid", "other", "lipids"], + groupBy: "category", + saved: true, + }); + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + expect(row?.moduleListLayoutJson).toEqual({ + version: 1, + labs: { + categoryOrder: ["thyroid", "other", "lipids"], + groupBy: "category", + }, + }); + + // An empty order is a choice too: back to the default order. + await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "labs", categoryOrder: [] }), + ); + const reset = (await (await (GET as Handler)(new Request(URL))).json()) as { + data: { lists: { labs: { categoryOrder: string[] } } }; + }; + expect(reset.data.lists.labs.categoryOrder).toEqual([]); + + const refused = await (PUT as Handler)( + json("PUT", URL, { + version: 1, + list: "labs", + categoryOrder: ["x".repeat(65), ""], + }), + ); + expect(refused.status).toBe(422); + }); + it("refuses unknown lists, values and keys with every issue named", async () => { await seedUser(); const { PUT } = await import("@/app/api/settings/module-list-layout/route"); From 6fb0d184f1e17352b2d2467c8b1ec38354835ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:08:36 +0100 Subject: [PATCH 06/46] fix(sync): hold a drained cursor a minute back so late commits arrive A page's watermark is its last row's stamp, and a long import stamps its rows before it commits them: a quick write committing in between carried the watermark past them, and the keyset never returned them. A caught-up page now hands out watermarks at most a minute before serverNow in every row domain, as start=now does, so the next pull reads that minute again. The mirror domain keeps its watermark. --- docs/api/openapi.yaml | 4 +- src/app/api/sync/changes/route.ts | 6 ++- src/lib/openapi/routes/sync.ts | 4 +- src/lib/sync/changes/pull.ts | 2 + src/lib/sync/cursor.ts | 28 +++++++++++++ tests/integration/apple-health-mirror.test.ts | 18 +++++++- .../sync-changes-start-now.test.ts | 42 ++++++++++++++++++- 7 files changed, 97 insertions(+), 7 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 21f8a4772..e4e977459 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50185,7 +50185,9 @@ components: - "null" hasMore: type: boolean - description: False once the client is caught up as of `serverNow`. + description: False once the client is caught up as of `serverNow`. The cursor of such a page sits at most a minute + before `serverNow` in each row domain, so the next pull reads rows changed in that last minute again (a + write still in flight may commit a row stamped there); upserts are idempotent. cursorExpired: type: boolean description: True when the supplied cursor predates tombstone retention, or was issued before the account's data was diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 6f126f02c..4d6574219 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -30,7 +30,11 @@ * server `id` (the client mirrors those by server id). * - `hasMore` is true when ANY served domain still has rows past its * page; the next `cursor` advances every domain that returned rows. - * When `hasMore` is false the client is caught up as of `serverNow`. + * When `hasMore` is false the client is caught up as of `serverNow`, + * and that page's cursor is held back to a minute before it in every + * row domain (`holdBackDrainedCursor`): a row stamped in that minute + * whose write commits after the pull still arrives on the next one, at + * the cost of reading that minute twice. * - `cursorExpired: true` when ANY domain watermark predates the * tombstone-retention horizon — the client drops its cursor and does * a clean initial sync (a deletion older than retention may have diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 3a6cb02ab..bfc9207a7 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -285,7 +285,9 @@ export const syncChangesResponse = z .describe("Opaque cursor to echo into the next request."), hasMore: z .boolean() - .describe("False once the client is caught up as of `serverNow`."), + .describe( + "False once the client is caught up as of `serverNow`. The cursor of such a page sits at most a minute before `serverNow` in each row domain, so the next pull reads rows changed in that last minute again (a write still in flight may commit a row stamped there); upserts are idempotent.", + ), cursorExpired: z .boolean() .describe( diff --git a/src/lib/sync/changes/pull.ts b/src/lib/sync/changes/pull.ts index 49dc08c7d..4b5ca8be6 100644 --- a/src/lib/sync/changes/pull.ts +++ b/src/lib/sync/changes/pull.ts @@ -14,6 +14,7 @@ import { decodeCursorIssuedAt, encodeCursor, encodeCursorAtNow, + holdBackDrainedCursor, SYNC_DOMAINS, type SyncCursor, type SyncDomain, @@ -232,6 +233,7 @@ export async function pullSyncChanges(input: { cd.hasMore || cy.hasMore || mm.hasMore; + if (!hasMore) holdBackDrainedCursor(nextCursor, serverNow.getTime()); annotate({ action: { name: "sync.changes.pull" }, diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index ab2fa1694..7cacd33ac 100644 --- a/src/lib/sync/cursor.ts +++ b/src/lib/sync/cursor.ts @@ -129,6 +129,34 @@ export function encodeCursorAtNow(nowMs: number): string { return encodeCursor(cursor, nowMs); } +/** + * Hold a drained walk's watermarks back to `START_NOW_OVERLAP_MS` before + * `nowMs`, in place. The watermark of a page is the stamp of its last row, + * and that stamp says nothing about rows still in flight: a long write (a + * Garmin or Withings chunk holds its transaction for seconds) stamps its rows + * first and commits them last, so a quick write that commits in between can + * carry the watermark past them, and the keyset would never return them. + * Held back a minute, the next pull reads the last minute again and the + * late rows come with it; anything older is passed as before. Only the + * cursor of a walk that is done is held back: one that still has pages keeps + * moving forward, or a minute holding more than a page could never drain. + * + * `measurementMirror` is left alone. Its watermark is a token's mirror + * stamp, written by one short update, and reading its last minute again + * would send every reading of a token whose mirror switch just flipped + * once more on every pull in that minute. + */ +export function holdBackDrainedCursor(cursor: SyncCursor, nowMs: number): void { + const ceiling = nowMs - START_NOW_OVERLAP_MS; + for (const domain of SYNC_DOMAINS) { + if (domain === "measurementMirror") continue; + const wm = cursor[domain]; + if (wm && wm.updatedAtMs > ceiling) { + cursor[domain] = { updatedAtMs: ceiling, id: "0" }; + } + } +} + function parseWatermark(raw: unknown): DomainWatermark | null { if (typeof raw !== "object" || raw === null) return null; const u = (raw as { u?: unknown }).u; diff --git a/tests/integration/apple-health-mirror.test.ts b/tests/integration/apple-health-mirror.test.ts index fb62b59d3..70ce6cdbf 100644 --- a/tests/integration/apple-health-mirror.test.ts +++ b/tests/integration/apple-health-mirror.test.ts @@ -259,7 +259,13 @@ describe("switching the setting", () => { const next = await callRoute<{ changes: { - measurements: { upserts: unknown[] }; + measurements: { + upserts: Array<{ + id: string; + updatedAt: string; + syncVersion: number; + }>; + }; measurementMirror: { upserts: Array<{ id: string; appleHealthMirror: boolean }>; }; @@ -270,7 +276,15 @@ describe("switching the setting", () => { query: { cursor: first.data.cursor }, auth: owner, }); - expect(next.data.changes.measurements.upserts).toEqual([]); + // The reading was written within the last minute, so the drained cursor + // reads it once more; the switch itself changed nothing about it. + expect( + next.data.changes.measurements.upserts.map((r) => ({ + id: r.id, + updatedAt: new Date(r.updatedAt), + syncVersion: r.syncVersion, + })), + ).toEqual([{ id, ...before }]); expect(next.data.changes.measurementMirror.upserts).toEqual([ { id, appleHealthMirror: true }, ]); diff --git a/tests/integration/sync-changes-start-now.test.ts b/tests/integration/sync-changes-start-now.test.ts index f77ee7591..72a63bb5d 100644 --- a/tests/integration/sync-changes-start-now.test.ts +++ b/tests/integration/sync-changes-start-now.test.ts @@ -138,12 +138,50 @@ describe("a cursor that starts now", () => { ); expect(mirrored.every((r) => r.appleHealthMirror === true)).toBe(true); - // And the cursor it hands back is a normal one: draining further is quiet. + // The drained cursor it hands back sits a minute back like the start + // one, so the row written in that minute comes once more and nothing + // else does. The mirror domain is not held back. const after = await pull({ cursor: next.cursor! }); - expect(after.changes.measurements.upserts).toEqual([]); + expect(after.changes.measurements.upserts.map((r) => r.id)).toEqual([ + fresh.id, + ]); expect(after.changes.measurementMirror.upserts).toEqual([]); }); + it("delivers a row stamped before a drained page's last row that commits after it", async () => { + // A long write stamps its rows first and commits them last; a quick + // write that commits in between moves the last row of the page past + // them. + const quick = await makeMeasurement( + { id: userId }, + { + source: "EXTERNAL", + ingestTokenId: scale.tokenId, + measuredAt: new Date(Date.UTC(2026, 10, 4, 7)), + externalId: "quick", + }, + ); + const first = await pull({}); + expect(first.hasMore).toBe(false); + expect(first.changes.measurements.upserts.map((r) => r.id)).toContain( + quick.id, + ); + const late = await makeMeasurement( + { id: userId }, + { + source: "EXTERNAL", + ingestTokenId: scale.tokenId, + measuredAt: new Date(Date.UTC(2026, 10, 4, 8)), + externalId: "late-after-page", + updatedAt: new Date(quick.updatedAt.getTime() - 1_000), + }, + ); + const next = await pull({ cursor: first.cursor! }); + expect(next.changes.measurements.upserts.map((r) => r.id)).toContain( + late.id, + ); + }); + it("delivers a row stamped before the cursor that commits after it", async () => { const start = await pull({ start: "now" }); // A write in flight when the cursor was handed out: its stamp predates From befbace4456139a8fb666cf08f163ac5a4fa7378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:08:37 +0100 Subject: [PATCH 07/46] fix(cycle): keep a feed turned off during a rotation off Rotation revoked the feed it had read without checking it was still live, so a turn-off in between came back under the new link, and it copied options read before a concurrent change. The revoke is guarded and the options are read after it; PATCH writes only to the live feed and follows a rotation to its successor. --- CHANGELOG.md | 14 ++++ .../cycle/feed-subscription/rotate/route.ts | 31 ++++++--- src/app/api/cycle/feed-subscription/route.ts | 22 +++++- tests/integration/cycle-calendar-feed.test.ts | 69 +++++++++++++++++++ 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 970b17acf..ece51a626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,20 @@ counts as no unit, and a marker already saved with the note accepts the plain unit. Any other text in brackets still counts as part of the unit. (#1313) +- **The sync feed no longer loses a reading written during a long + import.** A Garmin or Withings import writes a batch in one transaction + and stamps its readings before it commits them. When a reading logged by + hand committed in between and the app synced right then, the feed's + position moved past the import's readings, and they never reached the + app or Apple Health. A caught-up page of `GET /api/sync/changes` now + hands back a position a minute behind the present, like `start=now` + already does, so the next pull reads that minute again and anything + that committed late comes with it. +- **Changing the calendar subscription while its link is being renewed.** + A subscription turned off at the same moment its link was renewed could + come back under the new link, and an option changed at that moment + could be lost to the new link. Turning it off now wins, and the change + lands on the link that stays live. ## [1.47.1] — 2026-11-07 diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index fd9449577..70916a245 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -7,10 +7,11 @@ * The forecast style is copied too: a feed minted before that option keeps * BAND rather than taking a new feed's default. * Calendars holding the old link stop updating on their next poll. The new - * URL is returned exactly once. Two rotations racing both revoke the same - * feed, and the second successor then meets the one-live-feed index: that - * one gets the mint path's 409 `cycle.feed.exists`, and the winner's link - * stands. + * URL is returned exactly once. Of two rotations racing, the second finds + * the feed already revoked (or its successor meets the one-live-feed index) + * and gets the mint path's 409 `cycle.feed.exists`; the winner's link + * stands. A subscription turned off during a rotation stays off, and an + * option changed during one lands on the link that stays live. * * Same gates as turning the subscription on: cookie session only, the owner or * the guardian of a managed profile (`requireGuardianAuth`), a fresh proof, @@ -72,8 +73,23 @@ export const POST = apiHandler(async (request: NextRequest) => { const minted = mintFeedToken(); const rotated = await prisma .$transaction(async (tx) => { - const live = await tx.cycleCalendarFeed.findFirst({ + const found = await tx.cycleCalendarFeed.findFirst({ where: { userId: user.id, revokedAt: null }, + select: { id: true }, + }); + if (!found) return null; + // Only while it is still live: a subscription turned off after the + // read above stays off instead of coming back under a new link. + const revoked = await tx.cycleCalendarFeed.updateMany({ + where: { id: found.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (revoked.count === 0) return "raced" as const; + // The options are read after the revoke holds the row, so a change + // that committed in between is the one the new link carries, and one + // that comes later waits and then finds the new link. + const live = await tx.cycleCalendarFeed.findUniqueOrThrow({ + where: { id: found.id }, select: { id: true, horizonCycles: true, @@ -83,11 +99,6 @@ export const POST = apiHandler(async (request: NextRequest) => { forecastStyle: true, }, }); - if (!live) return null; - await tx.cycleCalendarFeed.update({ - where: { id: live.id }, - data: { revokedAt: new Date() }, - }); const created = await tx.cycleCalendarFeed.create({ data: { userId: user.id, diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 6d8b94516..30dc3e1e3 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -170,7 +170,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ); } - const live = await findLiveFeed(user.id); + let live = await findLiveFeed(user.id); if (!live) return noLiveFeed(); const data = { @@ -188,9 +188,25 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ? { forecastStyle: input.forecastStyle } : {}), }; - const updated = await prisma.cycleCalendarFeed.update({ - where: { id: live.id }, + // Only while the feed is still the live one. A rotation that committed + // after the read above revoked it and copied its old options onto the new + // link; the change then goes to that link, once. Updating the revoked row + // would answer 200 with the old options left in force. + let written = await prisma.cycleCalendarFeed.updateMany({ + where: { id: live.id, revokedAt: null }, data, + }); + if (written.count === 0) { + live = await findLiveFeed(user.id); + if (!live) return noLiveFeed(); + written = await prisma.cycleCalendarFeed.updateMany({ + where: { id: live.id, revokedAt: null }, + data, + }); + if (written.count === 0) return feedExists(); + } + const updated = await prisma.cycleCalendarFeed.findUniqueOrThrow({ + where: { id: live.id }, select: FEED_SUMMARY_SELECT, }); diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 7e271e72b..47c775d9d 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -11,6 +11,8 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Prisma } from "@/generated/prisma/client"; + import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; @@ -240,6 +242,73 @@ describe("turning the subscription on", () => { expect(statuses).toContain(409); }); + /** + * Run `rotate()` while `write` holds the live feed's row in an open + * transaction, and commit `write` only once the rotation is waiting on + * that row: the rotation has read the feed, and its revoke comes after + * the competing write whatever the scheduler does. + */ + async function rotateAround( + userId: string, + write: (tx: Prisma.TransactionClient, feedId: string) => Promise, + ): Promise { + const feed = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId, revokedAt: null }, + select: { id: true }, + }); + let pending: Promise | undefined; + await db().$transaction( + async (tx) => { + await write(tx, feed.id); + pending = rotate(); + for (let i = 0; i < 100; i++) { + const [{ n }] = await db().$queryRaw<{ n: number }[]>` + SELECT count(*)::int AS n FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'`; + if (n > 0) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error("the rotation never waited on the feed row"); + }, + { timeout: 10_000 }, + ); + return pending!; + } + + it("keeps a subscription that is turned off during a rotation off", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const res = await rotateAround(owner.id, async (tx, feedId) => { + await tx.cycleCalendarFeed.update({ + where: { id: feedId }, + data: { revokedAt: new Date() }, + }); + }); + expect(res.status).toBe(409); + expect( + await db().cycleCalendarFeed.count({ + where: { userId: owner.id, revokedAt: null }, + }), + ).toBe(0); + }); + + it("carries an option changed during a rotation onto the new link", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const res = await rotateAround(owner.id, async (tx, feedId) => { + await tx.cycleCalendarFeed.update({ + where: { id: feedId }, + data: { forecastStyle: "EXACT" }, + }); + }); + expect(res.status).toBe(201); + const live = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId: owner.id, revokedAt: null }, + select: { forecastStyle: true }, + }); + expect(live.forecastStyle).toBe("EXACT"); + }); + it("the partial unique index rejects a second live row written directly", async () => { const owner = await makeUser("owner"); const base = { From f75337e6f28d79b1d73a2b3d025853df3c286180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:28:57 +0100 Subject: [PATCH 08/46] feat(measurements): record blood ketones and show the glucose-ketone index (#1367) Ketones had to live in a custom metric, with the glucose-ketone index typed in by hand beside it, where it drifted from the readings it came from. - Type: BLOOD_KETONES (beta-hydroxybutyrate, mmol/L, migration 0496), plausible from 0 to 10: the blood ketone meters read 0.0 to 8.0 and print HI above it, and a venous value off a lab sheet fits under 10. Zero is a real reading. No band, no direction, no personal record, off the Coach snapshot and the correlations; readable over MCP and owned by the glucose module like glucose. - Entry: choosing blood ketones in the measurement form also takes the glucose from the same fingerstick, in the account's glucose unit. Both rows go through the array arm of POST /api/measurements with one timestamp, so they land in one transaction or not at all. - Index: glucose (mg/dL / 18.0182, the tree's one factor) over ketones, computed at read time and never stored. Each ketone reading above zero pairs with the glucose reading at the same instant, else the nearest one within 15 minutes either side; a tie goes to the same source, then to the earlier reading. Nothing pairs across a wider gap. - Surfaces: GET /api/measurements/series takes kind=ketones and kind=glucoseKetoneIndex (each point names the paired readings), and the day carries glucoseKetoneIndex when both reading types are visible. The new Ketones page charts the readings with the index beneath; the day view shows the index beside the value tiles; the doctor report, its PDF and the clinician share print it in the glucose group when both leaves are chosen, since beside the ketones it gives the glucose back. - FHIR: ketone readings export under LOINC 104816-4 (Beta hydroxybutyrate [Moles/volume] in Blood). The index is left out: it has no LOINC term, and both readings it is built from are in the bundle. - The measurement form's action row moved into its own component to keep the form inside its size budget. OpenAPI regenerated; all ten locales; the import unit table lists the type. --- CHANGELOG.md | 16 + docs/api/openapi.yaml | 113 +++++- docs/integrations/data-import.md | 1 + messages/de.json | 25 ++ messages/en.json | 25 ++ messages/es.json | 25 ++ messages/fr.json | 25 ++ messages/it.json | 25 ++ messages/ko.json | 25 ++ messages/nl.json | 25 ++ messages/pl.json | 25 ++ messages/pt-BR.json | 25 ++ messages/ru.json | 25 ++ .../0496_blood_ketones/migration.sql | 12 + prisma/schema.prisma | 4 + .../baselines/source-file-lines.json | 4 +- .../dense-measurement-read-guard.test.ts | 4 + ...doctor-report-control-gating-guard.test.ts | 4 +- .../unit-preference-display-guard.test.ts | 6 +- src/app/insights/ketones/page.tsx | 39 ++ src/components/clinician/report-sections.tsx | 15 + src/components/day/day-gki-tile.tsx | 46 +++ src/components/day/day-sections.tsx | 10 +- src/components/day/day-view.tsx | 2 +- .../insights/insights-tab-strip.tsx | 1 + src/components/insights/ketones/gki-card.tsx | 109 ++++++ .../__tests__/ketone-fingerstick.test.ts | 73 ++++ .../measurements/ketone-fingerstick.ts | 56 +++ .../measurements/measurement-form-parts.tsx | 127 +++++++ .../measurements/measurement-form.tsx | 101 +++--- .../measurements/measurement-list-meta.ts | 2 + .../measurements/measurement-type-colors.ts | 1 + .../measurement-type-enum-coverage.test.ts | 4 +- src/lib/dashboard-layout.ts | 1 + src/lib/day/load-day.ts | 15 + src/lib/day/wire-schemas.ts | 24 ++ .../measurements-charts-section.ts | 16 +- src/lib/doctor-report-types.ts | 7 + src/lib/doctor-report/collect.ts | 7 +- src/lib/doctor-report/glucose-ketone-index.ts | 45 +++ src/lib/fhir/__tests__/series.test.ts | 26 ++ src/lib/insights/chart-tokens.ts | 1 + src/lib/insights/metric-availability.ts | 2 + src/lib/insights/sub-page-metric.ts | 3 + .../__tests__/apple-health-mapping.test.ts | 3 + .../__tests__/glucose-ketone-index.test.ts | 156 ++++++++ src/lib/measurements/categories.ts | 1 + src/lib/measurements/glucose-ketone-index.ts | 189 ++++++++++ .../measurements/read-glucose-ketone-index.ts | 86 +++++ .../measurements/series-reference-lines.ts | 4 +- src/lib/measurements/series/kinds.ts | 23 ++ .../series/read-glucose-ketone-index.ts | 37 ++ src/lib/measurements/series/read-series.ts | 4 + src/lib/measurements/type-label-keys.ts | 1 + src/lib/measurements/unit-map.ts | 1 + src/lib/measurements/value-ranges.ts | 6 + src/lib/modules/measurement-scope.ts | 4 + src/lib/modules/surface.ts | 3 + src/lib/openapi/routes/measurements.ts | 23 +- src/lib/personal-records/pr-direction.ts | 8 +- src/lib/queries/use-glucose-ketone-index.ts | 50 +++ src/lib/query-keys/measurements.ts | 8 + .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 2 + .../fixtures/expected-measurement-loinc.json | 6 + src/lib/signals/registry.ts | 29 ++ src/lib/validations/measurement.ts | 2 + .../__golden__/backup-parity/refusals.json | 2 +- tests/integration/blood-ketones-gki.test.ts | 339 ++++++++++++++++++ .../analytics.02-summaries-cold.json.snap | 17 +- .../analytics.03-summaries-warm.json.snap | 17 +- .../analytics.04-default-cold.json.snap | 17 +- .../analytics.05-default-warm.json.snap | 17 +- .../analytics.08-empty-account.json.snap | 17 +- .../measurement-series.09-invalid.json.snap | 4 +- 75 files changed, 2133 insertions(+), 94 deletions(-) create mode 100644 prisma/migrations/0496_blood_ketones/migration.sql create mode 100644 src/app/insights/ketones/page.tsx create mode 100644 src/components/day/day-gki-tile.tsx create mode 100644 src/components/insights/ketones/gki-card.tsx create mode 100644 src/components/measurements/__tests__/ketone-fingerstick.test.ts create mode 100644 src/components/measurements/ketone-fingerstick.ts create mode 100644 src/components/measurements/measurement-form-parts.tsx create mode 100644 src/lib/doctor-report/glucose-ketone-index.ts create mode 100644 src/lib/measurements/__tests__/glucose-ketone-index.test.ts create mode 100644 src/lib/measurements/glucose-ketone-index.ts create mode 100644 src/lib/measurements/read-glucose-ketone-index.ts create mode 100644 src/lib/measurements/series/read-glucose-ketone-index.ts create mode 100644 src/lib/queries/use-glucose-ketone-index.ts create mode 100644 tests/integration/blood-ketones-gki.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf618510..b90cee9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,22 @@ prevention line 109, which answers around the clock. Before, Korean fell through to the international set with 112, which reaches the police there, not an ambulance. +- **Blood ketones, and the glucose-ketone index worked out for you.** + Ketones (beta-hydroxybutyrate, mmol/L) are a measurement of their own + now, so a ketogenic diet no longer has to live in a custom metric with + the index typed in by hand beside it. Choose "Blood ketones" when you + add a measurement and type the glucose from the same fingerstick into + the field below: both readings are saved together with one time, or + neither is. The glucose-ketone index is glucose in mmol/L divided by + ketones, taken from the glucose reading at the same time or, failing + that, the nearest one within 15 minutes. It is never stored, so it + follows any reading you edit or delete. It shows under the chart on the + new Ketones page, in the day view, and beside the ketones in the doctor + report and the clinician share; the FHIR export carries the ketone + readings under LOINC 104816-4. Values are shown as recorded, with no + band and no verdict. `GET /api/measurements/series` takes + `kind=ketones` and `kind=glucoseKetoneIndex`, and `GET /api/day/{date}` + carries `glucoseKetoneIndex`. Migration `0496_blood_ketones`. (#1367) ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3f21603d9..09fab7930 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4672,6 +4672,8 @@ paths: - restingHeartRate - heartRateVariability - vo2Max + - ketones + - glucoseKetoneIndex required: true - in: query name: days @@ -4811,6 +4813,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - in: query name: from schema: @@ -5736,6 +5739,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: The metric. required: true description: The metric. @@ -5866,6 +5870,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: The metric. required: true description: The metric. @@ -28773,7 +28778,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -28835,7 +28840,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -29715,6 +29720,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number measuredAt: @@ -31449,6 +31455,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES biomarkerId: type: string minLength: 1 @@ -31948,7 +31955,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -32593,7 +32600,7 @@ components: - order tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -32649,6 +32656,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -33151,6 +33159,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -37690,6 +37699,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones description: Which chart's overlays are being written. Closed enum. prefs: type: object @@ -37879,6 +37889,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -38851,7 +38862,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -38907,6 +38918,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -42716,6 +42728,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number valueMin: @@ -43484,6 +43497,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" biomarkerName: anyOf: @@ -46082,6 +46096,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -47282,6 +47297,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" intervalDays: anyOf: @@ -47508,6 +47524,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES metricSlot: type: - string @@ -51030,10 +51047,12 @@ components: - restingHeartRate - heartRateVariability - vo2Max + - ketones + - glucoseKetoneIndex unit: type: string description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` - (per-night time-asleep in hours). + (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`. points: type: array items: @@ -51080,6 +51099,31 @@ components: type: - number - "null" + gki: + description: "#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are + the ketone reading's. Absent for every other kind." + type: object + properties: + glucoseId: + type: string + description: The paired glucose reading's id. + glucoseAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The paired glucose reading's timestamp. + glucoseMmol: + type: number + description: The paired glucose in mmol/L, one decimal. + ketonesMmol: + type: number + description: The ketone reading (beta-hydroxybutyrate), mmol/L. + required: + - glucoseId + - glucoseAt + - glucoseMmol + - ketonesMmol + additionalProperties: false required: - id - at @@ -51137,7 +51181,10 @@ components: ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. - `stats` is always computed over the underlying readings, never over the buckets." + `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is + derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the + same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from + mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it." TrendVerdict: type: object properties: @@ -53823,6 +53870,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES date: type: string format: date @@ -54103,6 +54151,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES from: type: string format: date @@ -64552,7 +64601,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -64608,6 +64657,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -88209,6 +88259,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -88475,6 +88526,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -93849,6 +93901,14 @@ components: type: array items: $ref: "#/components/schemas/DayValue" + glucoseKetoneIndex: + description: "#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each + ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. + Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden + from the caller, and on older servers." + type: array + items: + $ref: "#/components/schemas/DayGlucoseKetoneIndex" events: type: array items: @@ -94053,6 +94113,43 @@ components: - source - band additionalProperties: false + DayGlucoseKetoneIndex: + type: object + properties: + at: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The ketone reading's instant. + value: + type: number + description: The index, to one decimal. + ketoneId: + type: string + description: The ketone reading's id. + glucoseId: + type: string + description: The paired glucose reading's id. + glucoseAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The paired glucose reading's instant. + ketonesMmol: + type: number + description: Beta-hydroxybutyrate, mmol/L. + glucoseMmol: + type: number + description: The paired glucose in mmol/L, one decimal. + required: + - at + - value + - ketoneId + - glucoseId + - glucoseAt + - ketonesMmol + - glucoseMmol + additionalProperties: false DayEvent: type: object properties: diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index 65d4c11b7..c59065e39 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -163,6 +163,7 @@ hand-authored. | `RESPIRATORY_RATE` | count/min | | `OXYGEN_SATURATION` | percent (0–100) | | `BLOOD_GLUCOSE` | mg/dL | +| `BLOOD_KETONES` | mmol/L | | `BODY_FAT` | percent (0–100) | | `FAT_MASS` | kg | | `FAT_FREE_MASS` | kg | diff --git a/messages/de.json b/messages/de.json index 578b94d21..13d9541d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -813,6 +813,10 @@ "typeSleep": "Schlaf", "typeSteps": "Schritte", "typeBloodGlucose": "Blutzucker", + "typeBloodKetones": "Blutketone", + "glucoseKetoneIndex": "Glukose-Keton-Index", + "pairedGlucoseLabel": "Blutzucker aus derselben Messung ({unit})", + "pairedGlucoseHint": "Wird als eigener Blutzuckerwert mit derselben Uhrzeit gespeichert; aus dem Paar ergibt sich der Glukose-Keton-Index.", "typeTotalBodyWater": "Gesamtkörperwasser", "typeBoneMass": "Knochenmasse", "typeOxygenSaturation": "Sauerstoffsättigung", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glukose", + "navBloodKetones": "Ketone", + "bloodKetones": { + "title": "Blutketone", + "description": "Deine Beta-Hydroxybutyrat-Werte im Zeitverlauf, in {unit}.", + "chartTitle": "Blutketone", + "emptyState": { + "title": "Noch keine Ketonwerte", + "description": "Ketonwerte erscheinen hier, sobald du einen erfasst, allein oder mit dem Blutzucker aus demselben Fingerstich.", + "cta": "Ketone erfassen" + }, + "gki": { + "description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.", + "emptyTitle": "Noch kein Index", + "emptyDescription": "Er erscheint, sobald zu einem Ketonwert ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.", + "latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Gesamtkörperwasser", "description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Alle {count} Werte", "valuesAllOther": "Alle {count} Werte", "valuesFewer": "Weniger Werte", + "gkiPair": "Blutzucker {glucose} / Ketone {ketones}", + "gkiCountOne": "Letzter von {count}", + "gkiCountFew": "Letzter von {count}", + "gkiCountOther": "Letzter von {count}", "countEntriesOne": "1 Eintrag", "countEntriesFew": "{count} Einträge", "countEntriesOther": "{count} Einträge", diff --git a/messages/en.json b/messages/en.json index 9d90b4bf4..3e9036e8e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -813,6 +813,10 @@ "typeSleep": "Sleep", "typeSteps": "Steps", "typeBloodGlucose": "Blood glucose", + "typeBloodKetones": "Blood ketones", + "glucoseKetoneIndex": "Glucose-ketone index", + "pairedGlucoseLabel": "Glucose from the same reading ({unit})", + "pairedGlucoseHint": "Saved as its own glucose reading at the same time; the glucose-ketone index is worked out from the pair.", "typeTotalBodyWater": "Total body water", "typeBoneMass": "Bone mass", "typeOxygenSaturation": "Oxygen saturation", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Ketones", + "bloodKetones": { + "title": "Blood Ketones", + "description": "Your beta-hydroxybutyrate readings over time, in {unit}.", + "chartTitle": "Blood ketones", + "emptyState": { + "title": "No ketone readings yet", + "description": "Ketone readings appear here once you log one, on its own or with the glucose from the same fingerstick.", + "cta": "Log ketones" + }, + "gki": { + "description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.", + "emptyTitle": "No index yet", + "emptyDescription": "It appears once a ketone reading has a glucose reading taken at the same time or within 15 minutes.", + "latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Total Body Water", "description": "Your total body water over time, in kilograms.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "All {count} values", "valuesAllOther": "All {count} values", "valuesFewer": "Fewer values", + "gkiPair": "Glucose {glucose} / ketones {ketones}", + "gkiCountOne": "Latest of {count}", + "gkiCountFew": "Latest of {count}", + "gkiCountOther": "Latest of {count}", "countEntriesOne": "1 entry", "countEntriesFew": "{count} entries", "countEntriesOther": "{count} entries", diff --git a/messages/es.json b/messages/es.json index 9dc9cf7c6..0e9fcb7e9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -813,6 +813,10 @@ "typeSleep": "Sueño", "typeSteps": "Pasos", "typeBloodGlucose": "Glucemia", + "typeBloodKetones": "Cetonas en sangre", + "glucoseKetoneIndex": "Índice glucosa-cetonas", + "pairedGlucoseLabel": "Glucosa de la misma medición ({unit})", + "pairedGlucoseHint": "Se guarda como una lectura de glucosa propia con la misma hora; el índice glucosa-cetonas se calcula a partir del par.", "typeTotalBodyWater": "Agua corporal total", "typeBoneMass": "Masa ósea", "typeOxygenSaturation": "Saturación de oxígeno", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucosa", + "navBloodKetones": "Cetonas", + "bloodKetones": { + "title": "Cetonas en sangre", + "description": "Tus lecturas de beta-hidroxibutirato en el tiempo, en {unit}.", + "chartTitle": "Cetonas en sangre", + "emptyState": { + "title": "Aún no hay lecturas de cetonas", + "description": "Las lecturas de cetonas aparecen aquí cuando registras una, sola o con la glucosa del mismo pinchazo.", + "cta": "Registrar cetonas" + }, + "gki": { + "description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.", + "emptyTitle": "Aún no hay índice", + "emptyDescription": "Aparece cuando una lectura de cetonas tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.", + "latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Agua corporal total", "description": "Tu agua corporal total en el tiempo, en kilogramos.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Los {count} valores", "valuesAllOther": "Los {count} valores", "valuesFewer": "Menos valores", + "gkiPair": "Glucosa {glucose} / cetonas {ketones}", + "gkiCountOne": "Último de {count}", + "gkiCountFew": "Último de {count}", + "gkiCountOther": "Último de {count}", "countEntriesOne": "1 entrada", "countEntriesFew": "{count} entradas", "countEntriesOther": "{count} entradas", diff --git a/messages/fr.json b/messages/fr.json index ebd9846e5..131d39a22 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -813,6 +813,10 @@ "typeSleep": "Sommeil", "typeSteps": "Pas", "typeBloodGlucose": "Glycémie", + "typeBloodKetones": "Cétones sanguines", + "glucoseKetoneIndex": "Indice glucose-cétones", + "pairedGlucoseLabel": "Glycémie de la même mesure ({unit})", + "pairedGlucoseHint": "Enregistrée comme un relevé de glycémie à la même heure ; l’indice glucose-cétones est calculé à partir de la paire.", "typeTotalBodyWater": "Eau corporelle totale", "typeBoneMass": "Masse osseuse", "typeOxygenSaturation": "Saturation en oxygène", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Cétones", + "bloodKetones": { + "title": "Cétones sanguines", + "description": "Vos relevés de bêta-hydroxybutyrate au fil du temps, en {unit}.", + "chartTitle": "Cétones sanguines", + "emptyState": { + "title": "Pas encore de relevé de cétones", + "description": "Les relevés de cétones apparaissent ici dès que vous en saisissez un, seul ou avec la glycémie de la même piqûre.", + "cta": "Enregistrer les cétones" + }, + "gki": { + "description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.", + "emptyTitle": "Pas encore d’indice", + "emptyDescription": "Il apparaît dès qu’un relevé de cétones a une glycémie prise au même moment ou à 15 minutes près.", + "latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Eau corporelle totale", "description": "Votre eau corporelle totale au fil du temps, en kilogrammes.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Les {count} valeurs", "valuesAllOther": "Les {count} valeurs", "valuesFewer": "Moins de valeurs", + "gkiPair": "Glycémie {glucose} / cétones {ketones}", + "gkiCountOne": "Dernier sur {count}", + "gkiCountFew": "Dernier sur {count}", + "gkiCountOther": "Dernier sur {count}", "countEntriesOne": "{count} entrée", "countEntriesFew": "{count} entrées", "countEntriesOther": "{count} entrées", diff --git a/messages/it.json b/messages/it.json index f77f140ee..1be30eba6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -813,6 +813,10 @@ "typeSleep": "Sonno", "typeSteps": "Passi", "typeBloodGlucose": "Glicemia", + "typeBloodKetones": "Chetoni nel sangue", + "glucoseKetoneIndex": "Indice glucosio-chetoni", + "pairedGlucoseLabel": "Glicemia della stessa misurazione ({unit})", + "pairedGlucoseHint": "Salvata come lettura di glicemia a sé con lo stesso orario; l’indice glucosio-chetoni si ricava dalla coppia.", "typeTotalBodyWater": "Acqua corporea totale", "typeBoneMass": "Massa ossea", "typeOxygenSaturation": "Saturazione di ossigeno", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucosio", + "navBloodKetones": "Chetoni", + "bloodKetones": { + "title": "Chetoni nel sangue", + "description": "Le tue letture di beta-idrossibutirrato nel tempo, in {unit}.", + "chartTitle": "Chetoni nel sangue", + "emptyState": { + "title": "Ancora nessuna lettura di chetoni", + "description": "Le letture di chetoni appaiono qui quando ne registri una, da sola o con la glicemia della stessa puntura.", + "cta": "Registra i chetoni" + }, + "gki": { + "description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.", + "emptyTitle": "Ancora nessun indice", + "emptyDescription": "Compare quando una lettura di chetoni ha una glicemia presa allo stesso momento o entro 15 minuti.", + "latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Acqua corporea totale", "description": "La tua acqua corporea totale nel tempo, in chilogrammi.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Tutti i {count} valori", "valuesAllOther": "Tutti i {count} valori", "valuesFewer": "Meno valori", + "gkiPair": "Glicemia {glucose} / chetoni {ketones}", + "gkiCountOne": "Ultimo di {count}", + "gkiCountFew": "Ultimo di {count}", + "gkiCountOther": "Ultimo di {count}", "countEntriesOne": "1 voce", "countEntriesFew": "{count} voci", "countEntriesOther": "{count} voci", diff --git a/messages/ko.json b/messages/ko.json index 1c2173f54..91c6d1cb0 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -813,6 +813,10 @@ "typeSleep": "수면", "typeSteps": "걸음 수", "typeBloodGlucose": "혈당", + "typeBloodKetones": "혈중 케톤", + "glucoseKetoneIndex": "포도당-케톤 지수", + "pairedGlucoseLabel": "같은 측정의 혈당 ({unit})", + "pairedGlucoseHint": "같은 시각의 별도 혈당 기록으로 저장되고, 두 값으로 포도당-케톤 지수를 계산해요.", "typeTotalBodyWater": "체수분", "typeBoneMass": "골량", "typeOxygenSaturation": "산소포화도", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "혈당", + "navBloodKetones": "케톤", + "bloodKetones": { + "title": "혈중 케톤", + "description": "시간에 따른 베타-하이드록시부티르산 측정값이에요({unit}).", + "chartTitle": "혈중 케톤", + "emptyState": { + "title": "아직 케톤 측정값이 없어요", + "description": "케톤을 단독으로 또는 같은 채혈의 혈당과 함께 기록하면 여기에 측정값이 나타나요.", + "cta": "케톤 기록" + }, + "gki": { + "description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.", + "emptyTitle": "아직 지수가 없어요", + "emptyDescription": "케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.", + "latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "총 체수분", "description": "시간에 따른 총 체수분이에요(kg).", @@ -13687,6 +13708,10 @@ "valuesAllFew": "수치 {count}개 모두", "valuesAllOther": "수치 {count}개 모두", "valuesFewer": "수치 줄여 보기", + "gkiPair": "혈당 {glucose} / 케톤 {ketones}", + "gkiCountOne": "{count}개 중 최근", + "gkiCountFew": "{count}개 중 최근", + "gkiCountOther": "{count}개 중 최근", "countEntriesOne": "기록 1개", "countEntriesFew": "기록 {count}개", "countEntriesOther": "기록 {count}개", diff --git a/messages/nl.json b/messages/nl.json index 9d4dafbdd..03178e76b 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -813,6 +813,10 @@ "typeSleep": "Slaap", "typeSteps": "Stappen", "typeBloodGlucose": "Bloedglucose", + "typeBloodKetones": "Bloedketonen", + "glucoseKetoneIndex": "Glucose-ketonindex", + "pairedGlucoseLabel": "Glucose uit dezelfde meting ({unit})", + "pairedGlucoseHint": "Wordt als eigen glucosemeting met hetzelfde tijdstip opgeslagen; uit het paar volgt de glucose-ketonindex.", "typeTotalBodyWater": "Totaal lichaamswater", "typeBoneMass": "Botmassa", "typeOxygenSaturation": "Zuurstofsaturatie", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Ketonen", + "bloodKetones": { + "title": "Bloedketonen", + "description": "Je bèta-hydroxybutyraatmetingen in de loop van de tijd, in {unit}.", + "chartTitle": "Bloedketonen", + "emptyState": { + "title": "Nog geen ketonmetingen", + "description": "Ketonmetingen verschijnen hier zodra je er een vastlegt, los of met de glucose uit dezelfde vingerprik.", + "cta": "Ketonen vastleggen" + }, + "gki": { + "description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.", + "emptyTitle": "Nog geen index", + "emptyDescription": "Hij verschijnt zodra een ketonmeting een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.", + "latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Totaal lichaamswater", "description": "Je totale lichaamswater in de loop van de tijd, in kilogram.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Alle {count} waarden", "valuesAllOther": "Alle {count} waarden", "valuesFewer": "Minder waarden", + "gkiPair": "Glucose {glucose} / ketonen {ketones}", + "gkiCountOne": "Laatste van {count}", + "gkiCountFew": "Laatste van {count}", + "gkiCountOther": "Laatste van {count}", "countEntriesOne": "1 registratie", "countEntriesFew": "{count} registraties", "countEntriesOther": "{count} registraties", diff --git a/messages/pl.json b/messages/pl.json index ea16d9a0d..a545dab25 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -813,6 +813,10 @@ "typeSleep": "Sen", "typeSteps": "Kroki", "typeBloodGlucose": "Glikemia", + "typeBloodKetones": "Ketony we krwi", + "glucoseKetoneIndex": "Indeks glukozowo-ketonowy", + "pairedGlucoseLabel": "Glukoza z tego samego pomiaru ({unit})", + "pairedGlucoseHint": "Zapisywana jako osobny odczyt glukozy z tą samą godziną; z tej pary obliczany jest indeks glukozowo-ketonowy.", "typeTotalBodyWater": "Całkowita woda w organizmie", "typeBoneMass": "Masa kostna", "typeOxygenSaturation": "Saturacja tlenem", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glukoza", + "navBloodKetones": "Ketony", + "bloodKetones": { + "title": "Ketony we krwi", + "description": "Twoje odczyty beta-hydroksymaślanu w czasie, w {unit}.", + "chartTitle": "Ketony we krwi", + "emptyState": { + "title": "Brak jeszcze odczytów ketonów", + "description": "Odczyty ketonów pojawią się tutaj po zapisaniu jednego, osobno lub z glukozą z tego samego nakłucia.", + "cta": "Zapisz ketony" + }, + "gki": { + "description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.", + "emptyTitle": "Brak jeszcze indeksu", + "emptyDescription": "Pojawi się, gdy do odczytu ketonów będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.", + "latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Całkowita woda w organizmie", "description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Wszystkie {count} wartości", "valuesAllOther": "Wszystkie {count} wartości", "valuesFewer": "Mniej wartości", + "gkiPair": "Glukoza {glucose} / ketony {ketones}", + "gkiCountOne": "Ostatni z {count}", + "gkiCountFew": "Ostatni z {count}", + "gkiCountOther": "Ostatni z {count}", "countEntriesOne": "1 wpis", "countEntriesFew": "{count} wpisy", "countEntriesOther": "{count} wpisów", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6ff853515..e16d8bfb9 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -813,6 +813,10 @@ "typeSleep": "Sono", "typeSteps": "Passos", "typeBloodGlucose": "Glicemia", + "typeBloodKetones": "Cetonas no sangue", + "glucoseKetoneIndex": "Índice glicose-cetona", + "pairedGlucoseLabel": "Glicose da mesma medição ({unit})", + "pairedGlucoseHint": "Salva como uma leitura de glicose própria no mesmo horário; o índice glicose-cetona é calculado a partir do par.", "typeTotalBodyWater": "Água corporal total", "typeBoneMass": "Massa óssea", "typeOxygenSaturation": "Saturação de oxigênio", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glicose", + "navBloodKetones": "Cetonas", + "bloodKetones": { + "title": "Cetonas no sangue", + "description": "Suas leituras de beta-hidroxibutirato ao longo do tempo, em {unit}.", + "chartTitle": "Cetonas no sangue", + "emptyState": { + "title": "Ainda não há leituras de cetonas", + "description": "As leituras de cetonas aparecem aqui assim que você registra uma, sozinha ou com a glicose da mesma picada.", + "cta": "Registrar cetonas" + }, + "gki": { + "description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.", + "emptyTitle": "Ainda não há índice", + "emptyDescription": "Ele aparece quando uma leitura de cetonas tem uma de glicose feita no mesmo horário ou em até 15 minutos.", + "latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Água Corporal Total", "description": "Sua água corporal total ao longo do tempo, em quilogramas.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Todos os {count} valores", "valuesAllOther": "Todos os {count} valores", "valuesFewer": "Menos valores", + "gkiPair": "Glicose {glucose} / cetonas {ketones}", + "gkiCountOne": "Último de {count}", + "gkiCountFew": "Último de {count}", + "gkiCountOther": "Último de {count}", "countEntriesOne": "{count} entrada", "countEntriesFew": "{count} entradas", "countEntriesOther": "{count} entradas", diff --git a/messages/ru.json b/messages/ru.json index e449bbec9..65b4e71d8 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -813,6 +813,10 @@ "typeSleep": "Сон", "typeSteps": "Шаги", "typeBloodGlucose": "Глюкоза крови", + "typeBloodKetones": "Кетоны в крови", + "glucoseKetoneIndex": "Глюкозо-кетоновый индекс", + "pairedGlucoseLabel": "Глюкоза из того же измерения ({unit})", + "pairedGlucoseHint": "Сохраняется как отдельное измерение глюкозы с тем же временем; по этой паре рассчитывается глюкозо-кетоновый индекс.", "typeTotalBodyWater": "Общая вода в организме", "typeBoneMass": "Костная масса", "typeOxygenSaturation": "Насыщение кислородом", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Глюкоза", + "navBloodKetones": "Кетоны", + "bloodKetones": { + "title": "Кетоны в крови", + "description": "Ваши измерения бета-гидроксибутирата в динамике, в {unit}.", + "chartTitle": "Кетоны в крови", + "emptyState": { + "title": "Измерений кетонов пока нет", + "description": "Измерения кетонов появятся здесь, как только вы запишете одно, отдельно или вместе с глюкозой из того же прокола.", + "cta": "Записать кетоны" + }, + "gki": { + "description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.", + "emptyTitle": "Индекса пока нет", + "emptyDescription": "Он появится, когда к измерению кетонов будет измерение глюкозы в то же время или в пределах 15 минут.", + "latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Общая вода в организме", "description": "Ваша общая вода в организме в динамике, в килограммах.", @@ -13687,6 +13708,10 @@ "valuesAllFew": "Все {count} значения", "valuesAllOther": "Все {count} значений", "valuesFewer": "Меньше значений", + "gkiPair": "Глюкоза {glucose} / кетоны {ketones}", + "gkiCountOne": "Последний из {count}", + "gkiCountFew": "Последний из {count}", + "gkiCountOther": "Последний из {count}", "countEntriesOne": "{count} запись", "countEntriesFew": "Записей: {count}", "countEntriesOther": "Записей: {count}", diff --git a/prisma/migrations/0496_blood_ketones/migration.sql b/prisma/migrations/0496_blood_ketones/migration.sql new file mode 100644 index 000000000..c0d43bbbc --- /dev/null +++ b/prisma/migrations/0496_blood_ketones/migration.sql @@ -0,0 +1,12 @@ +-- Extend `measurement_type` with blood ketones (#1367): +-- +-- BLOOD_KETONES <- beta-hydroxybutyrate from a blood ketone meter (mmol/L) +-- +-- Purely additive: no existing row carries the value, and nothing in this +-- file uses it, so the rule that a value added in a transaction cannot be used +-- in that same transaction never comes into play. IF NOT EXISTS keeps a replay +-- on a partially applied database harmless. Enum values cannot be dropped in +-- PostgreSQL, so there is no down path. The glucose-ketone index is computed +-- at read time and has no column. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_KETONES'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbf05d6e6..80f3d5962 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1816,6 +1816,10 @@ enum MeasurementType { // v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED, // never folded into it and never summed with it into a total. BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM. + // v1.47.2 — blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick + // meter, typed by hand. The glucose-ketone index is derived from this and + // BLOOD_GLUCOSE at read time and never stored. + BLOOD_KETONES // mmol/L — capillary or venous beta-hydroxybutyrate (BHB); spot reading, never summed. @@map("measurement_type") } diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1770cb655..e6d9b833e 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -11,7 +11,7 @@ "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, - "components/insights/insights-tab-strip.tsx": 1086, + "components/insights/insights-tab-strip.tsx": 1087, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1157, "components/medications/medication-table.tsx": 853, @@ -39,7 +39,7 @@ "lib/daily/digest.ts": 1317, "lib/daily/load-digest.ts": 1000, "lib/daily/today-overview.ts": 838, - "lib/dashboard-layout.ts": 976, + "lib/dashboard-layout.ts": 977, "lib/dashboard/snapshot.ts": 1550, "lib/day/records.ts": 1174, "lib/export/backup-plan.ts": 838, diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index dc52ec8a1..34880beb6 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -149,6 +149,10 @@ const ALLOWED_SQL: Record = { "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", "lib/dashboard/summary-reads.ts::1": "Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.", + "lib/measurements/read-glucose-ketone-index.ts::1": + "Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.", + "lib/measurements/read-glucose-ketone-index.ts::2": + "Glucose rows only within 15 minutes of one of those ketone readings (`EXISTS`): a handful per fingerstick even under a sensor, never the window's glucose series.", "lib/dashboard/summary-reads.ts::2": "Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.", }; diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 52bf07926..8dfdae6b6 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(111); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91); + expect(ALL_LEAF_IDS.length).toBe(112); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20); }); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 79c26c6fd..e2d36f53b 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "the onboarding unit picker: its options are the two units", }, "components/measurements/measurement-form.tsx": { - lines: 11, + lines: 12, reason: "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", }, @@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "the legacy export endpoint's canonical storage-unit contract", }, "lib/measurements/series/kinds.ts": { - lines: 4, + lines: 5, reason: - "canonical wire units; glucose is overridden to the reader's unit per request", + "canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice", }, "lib/doctor-report/stat-display.ts": { lines: 3, diff --git a/src/app/insights/ketones/page.tsx b/src/app/insights/ketones/page.tsx new file mode 100644 index 000000000..27899b8c3 --- /dev/null +++ b/src/app/insights/ketones/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Droplet } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { GkiCard } from "@/components/insights/ketones/gki-card"; +import { getUnitForType } from "@/lib/measurements/unit-map"; + +/** mmol/L, from the one canonical unit table; ketones have no unit choice. */ +const KETONE_UNIT = getUnitForType("BLOOD_KETONES"); + +/** + * `/insights/ketones` (#1367). + * + * Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric + * scaffold, with the glucose-ketone index beneath the chart. Readings are + * typed off a meter, alone or together with the glucose from the same + * fingerstick, so the empty state offers manual entry. No band and no + * assessment card: what a ketone reading should be follows the plan a + * clinician sets, and the page shows the readings as recorded. + */ +export default function InsightsKetonesPage() { + return ( + } + emptyStateCtaType="BLOOD_KETONES" + captureType="BLOOD_KETONES" + afterChart={} + /> + ); +} diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index ce8a9a162..c1251cb8d 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -261,6 +261,21 @@ export function MeasurementGroups({ max: show(type, stat!.max, digits), })} /> + {type === "BLOOD_KETONES" && + report.glucoseKetoneIndex && + scope.admits("BLOOD_GLUCOSE") ? ( + // #1367 — the index the server derived from the ketone + // and glucose readings, beside the ketones; no unit. + + ) : null} {unvalidated > 0 && (

[number]; + +/** + * The day's glucose-ketone index (#1367), one tile beside the value tiles. + * Every figure is the server's: the index and the two readings it was built + * from. A day with one fingerstick shows its index and the pair; a day with + * several shows the latest index and how many there were, and the ketones page + * lists each. Nothing grades the number. + */ +export function DayGkiTile({ points }: { points: readonly DayGki[] }) { + const { t, tCount } = useTranslations(); + const fmt = useFormatters(); + const latest = points[points.length - 1]; + if (!latest) return null; + return ( +

  • + + + {t("measurements.glucoseKetoneIndex")} + + + {fmt.number(latest.value, 1)} + + + {points.length > 1 + ? tCount("day.gkiCount", points.length) + : t("day.gkiPair", { + glucose: fmt.number(latest.glucoseMmol, 1), + ketones: fmt.number(latest.ketonesMmol, 1), + })} + + +
  • + ); +} diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index 498d06e2d..bd44b5729 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -29,9 +29,9 @@ import { type DayEvent, type DayEventKind, type DayNotable, + type DayResponse, type DayRunningItem, type DayRunningKind, - type DayValue, } from "@/lib/day/contract"; import { visitKindWords } from "@/components/timeline/item-words"; import { @@ -62,6 +62,7 @@ import { WORKOUT_SPORT_KEY, } from "./label-keys"; import { cycleRunningLine } from "./day-cycle-line"; +import { DayGkiTile } from "./day-gki-tile"; import { useDayValueFormat } from "./use-day-value-format"; /** @@ -508,12 +509,14 @@ function ValueTile({ } export function DayValues({ - values, + day, focusTypes, }: { - values: readonly DayValue[]; + /** The day's values and its server-derived glucose-ketone index (#1367). */ + day: Pick; focusTypes: readonly string[]; }) { + const { values } = day; const { t, tCount } = useTranslations(); const [expanded, setExpanded] = useState(false); if (values.length === 0) return null; @@ -537,6 +540,7 @@ export function DayValues({ selected={focusKeys.has(tile.key)} /> ))} + {rest.length > 0 ? ( + + + + + {t("measurements.formReset")} + + + + +
    + {onCancel && ( + + )} + +
    +
    + ); +} + +/** + * #1367 — the glucose a ketone meter reads from the same drop, beside the + * ketones. Optional: a meter that reads ketones only leaves it empty. It asks + * in the account's glucose unit, which the write path converts back to mg/dL. + */ +export function PairedGlucoseField({ + value, + onChange, + unit, + invalid, + describedBy, +}: { + value: string; + onChange: (next: string) => void; + unit: GlucoseUnit; + invalid: boolean; + describedBy?: string; +}) { + const { t } = useTranslations(); + return ( + + {t("measurements.pairedGlucoseLabel", { unit })} + + ({t("common.optional")}) + + + } + hint={t("measurements.pairedGlucoseHint")} + > + onChange(e.target.value)} + placeholder={glucoseFractionDigits(unit) > 0 ? "5.3" : "95"} + aria-invalid={invalid || undefined} + aria-describedby={describedBy} + /> + + ); +} diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 4a4fd42e6..76e7b2f95 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -5,13 +5,6 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useState } from "react"; import { createPortal } from "react-dom"; import { useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { DateTimeField } from "@/components/ui/date-time-field"; import { FieldGroup } from "@/components/ui/field-group"; @@ -23,7 +16,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; @@ -50,6 +42,11 @@ import { } from "@/lib/measurements/last-used-type"; import { DurationReadAs } from "./duration-read-as"; +import { ketoneFingerstickBatch } from "./ketone-fingerstick"; +import { + MeasurementFormFooter, + PairedGlucoseField, +} from "./measurement-form-parts"; const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH; @@ -104,6 +101,13 @@ export const MEASUREMENT_TYPES = [ unit: "mg/dL", placeholder: "95", }, + // #1367 — ketones, with the same fingerstick's glucose (one entry). + { + value: "BLOOD_KETONES", + labelKey: "measurements.typeBloodKetones", + unit: "mmol/L", + placeholder: "1.2", + }, { value: "TOTAL_BODY_WATER", labelKey: "measurements.typeTotalBodyWater", @@ -360,6 +364,8 @@ export function MeasurementForm({ const [sysBp, setSysBp] = useState(""); const [diaBp, setDiaBp] = useState(""); const [pulse, setPulse] = useState(""); + // The glucose of a fingerstick entered with its ketones; optional. + const [pairedGlucose, setPairedGlucose] = useState(""); const [notes, setNotes] = useState(""); const [measuredAt, setMeasuredAt] = useState(() => localDateTimeValue(defaultDate), @@ -383,6 +389,7 @@ export function MeasurementForm({ const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type); const isBpMode = type === "BLOOD_PRESSURE"; const isGlucoseMode = type === "BLOOD_GLUCOSE"; + const isKetoneMode = type === "BLOOD_KETONES"; // A duration field (sleep, in hours) also reads "7:30" and "7h 30m"; the // hint under it says how the entry was understood. const durationUnit = durationEntryUnit(type); @@ -395,6 +402,7 @@ export function MeasurementForm({ setSysBp(""); setDiaBp(""); setPulse(""); + setPairedGlucose(""); setNotes(""); setMeasuredAt(localDateTimeValue(defaultDate)); setError(null); @@ -440,6 +448,21 @@ export function MeasurementForm({ }); } + await apiPost("/api/measurements", batch); + } else if (isKetoneMode) { + // One fingerstick, posted as one transaction (`ketone-fingerstick.ts`). + const batch = ketoneFingerstickBatch({ + ketones: value, + glucose: pairedGlucose, + glucoseUnit, + glucoseContext, + measuredAt: timestamp, + notes, + }); + if (!batch) { + setError(t("measurements.saveError")); + return; + } await apiPost("/api/measurements", batch); } else { // Single measurement. Canonical storage stays SI: an imperial user @@ -503,6 +526,7 @@ export function MeasurementForm({ setSysBp(""); setDiaBp(""); setPulse(""); + setPairedGlucose(""); setNotes(""); await invalidateKeys(queryClient, measurementDependentKeys); await refetchInactiveDailyReads(queryClient); @@ -528,49 +552,12 @@ export function MeasurementForm({ } const footerNode = ( -
    - - - - - - - - {t("measurements.formReset")} - - - - -
    - {onCancel && ( - - )} - -
    -
    + ); return ( @@ -718,7 +705,17 @@ export function MeasurementForm({ )} - {isGlucoseMode && ( + {isKetoneMode && ( + + )} + + {(isGlucoseMode || (isKetoneMode && pairedGlucose.trim() !== "")) && ( = { SLEEP_DURATION: "measurements.typeSleep", ACTIVITY_STEPS: "measurements.typeSteps", BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + BLOOD_KETONES: "measurements.typeBloodKetones", TOTAL_BODY_WATER: "measurements.typeTotalBodyWater", BONE_MASS: "measurements.typeBoneMass", OXYGEN_SATURATION: "measurements.typeOxygenSaturation", @@ -159,6 +160,7 @@ export const MEASUREMENT_TYPE_ICONS: Record = { SLEEP_DURATION: Moon, ACTIVITY_STEPS: Footprints, BLOOD_GLUCOSE: Droplet, + BLOOD_KETONES: Droplet, TOTAL_BODY_WATER: Droplet, BONE_MASS: Bone, OXYGEN_SATURATION: Wind, diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index cd317846c..1b97cce47 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -13,6 +13,7 @@ export const MEASUREMENT_TYPE_COLORS: Record = { SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + BLOOD_KETONES: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index f65401533..ac4322fbb 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -131,10 +131,12 @@ const EXPECTED_TYPES = [ "ACUTE_TRAINING_LOAD", // ── resting energy (#1340) ── "BASAL_ENERGY_BURNED", + // ── blood ketones (#1367) ── + "BLOOD_KETONES", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 91 canonical measurement types", () => { + it("exposes the 92 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index fb9dac05e..32dd2cfa8 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [ "scoreSleep", "scoreStress", "scoreStrain", + "bloodKetones", // #1367 — the chart on the ketones page. ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; diff --git a/src/lib/day/load-day.ts b/src/lib/day/load-day.ts index 0d517c770..675dd52bc 100644 --- a/src/lib/day/load-day.ts +++ b/src/lib/day/load-day.ts @@ -29,6 +29,7 @@ import { import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores"; import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; import { readDayBands, shapeDayValues } from "@/lib/day/values"; +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; import { resolveDayReadings, type DayReadings, @@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise { ]); const presentTypes = [...readings.rowsByType.keys()]; + // The index reveals the glucose it was built from, so it needs both + // reading types visible, not only the ketones the day holds. + const gkiReadable = + readings.rowsByType.has("BLOOD_KETONES") && + typeVisible("BLOOD_KETONES") && + typeVisible("BLOOD_GLUCOSE"); + const glucoseKetoneIndex = gkiReadable + ? await readGlucoseKetoneIndex({ + userId: recordId, + from: dayStart, + to: dayEnd, + }) + : []; const [bands, notable] = presentTypes.length === 0 ? [new Map(), []] @@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise { counts: { values: values.length, entries: events.length }, running, values, + ...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}), events, notable: notable.map(({ kind, type, params }) => ({ kind, type, params })), scores, diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 2f25fae0e..cb3a400b1 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -117,6 +117,24 @@ const dayValue = z }) .meta({ id: "DayValue" }); +/** + * #1367 — one glucose-ketone index of the day, derived from a ketone reading + * and the glucose reading paired with it; never stored. + */ +const dayGlucoseKetoneIndex = z + .object({ + at: instant.describe("The ketone reading's instant."), + value: z.number().describe("The index, to one decimal."), + ketoneId: z.string().describe("The ketone reading's id."), + glucoseId: z.string().describe("The paired glucose reading's id."), + glucoseAt: instant.describe("The paired glucose reading's instant."), + ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."), + glucoseMmol: z + .number() + .describe("The paired glucose in mmol/L, one decimal."), + }) + .meta({ id: "DayGlucoseKetoneIndex" }); + const dayEvent = z .object({ at: instant @@ -232,6 +250,12 @@ export const dayResponseSchema = z }), running: z.array(dayRunningItem), values: z.array(dayValue), + glucoseKetoneIndex: z + .array(dayGlucoseKetoneIndex) + .optional() + .describe( + "#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.", + ), events: z.array(dayEvent), notable: z.array(dayNotable), scores: z diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 700fbabd6..30edb5bf2 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow { } /** - * Per-context glucose rows. Values are stored canonically in mg/dL and print - * in the owner's unit, in the precision their readings carry. + * The glucose group's own rows: one per meal context, stored canonically in + * mg/dL and printed in the owner's unit in the precision their readings + * carry, then the glucose-ketone index (#1367), a ratio without a unit. */ function glucoseContextTableRows( context: DoctorReportPdfRenderContext, @@ -209,6 +210,17 @@ function glucoseContextTableRows( String(s.count), ]); } + const gki = data.glucoseKetoneIndex; + if (gki) { + rows.push([ + t("measurements.glucoseKetoneIndex"), + num(gki.latest, 1), + num(gki.avg, 1), + num(gki.min, 1), + num(gki.max, 1), + String(gki.count), + ]); + } return rows; } diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index ec7c151c4..b82c4fffe 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -118,6 +118,13 @@ export interface DoctorReportData { * `stillLearning` when the period is too thin to assert clinically. */ glucoseClinical: GlucoseClinicalMetrics; + /** + * #1367 — the glucose-ketone index over the period (count, mean, range, + * latest), derived at read time from each ketone reading and the glucose + * reading paired with it. Null when there is none or when either reading + * type is withheld. Optional so older fixtures still typecheck. + */ + glucoseKetoneIndex?: DoctorReportStats | null; bmi: number | null; /** Keyed by medication id; see `DoctorReportCompliance`. */ compliance: Record; diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 79b431564..e40039f56 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -67,6 +67,7 @@ import { import { loadDenseMeasurementBuckets } from "./dense-buckets"; import { summariseDenseBuckets } from "./measurement-series"; import { emptyGlucoseClinical } from "./glucose-panel"; +import { readReportGki } from "./glucose-ketone-index"; import { buildAdministrationLedger, buildGlp1Block } from "./medications"; import { loadReportWorkouts } from "./workouts"; import { loadReportMedicationEffects } from "./medication-effects"; @@ -249,9 +250,8 @@ export async function collectDoctorReportData( ...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}), }, orderBy: { measuredAt: "asc" }, - // Narrow select: the collector reads exactly these seven fields. - // The full-width read pulled every column — including the encrypted - // notes — across up to 730 days of rows. + // Narrow select: the full-width read pulled every column, encrypted + // notes included, across up to 730 days of rows. select: { type: true, value: true, @@ -739,6 +739,7 @@ export async function collectDoctorReportData( glucoseStats, glucoseRanges, glucoseClinical, + glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end), glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null), bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null, compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {}, diff --git a/src/lib/doctor-report/glucose-ketone-index.ts b/src/lib/doctor-report/glucose-ketone-index.ts new file mode 100644 index 000000000..cd20865ba --- /dev/null +++ b/src/lib/doctor-report/glucose-ketone-index.ts @@ -0,0 +1,45 @@ +/** + * The glucose-ketone index in the doctor report (#1367): count, mean, range + * and latest over the report window, read by the one pairing rule + * (`read-glucose-ketone-index.ts`) so the report carries the numbers the + * ketones page shows. + * + * Both leaves must admit it. The index is glucose over ketones, so beside the + * ketone series it gives the glucose back exactly: a report that withholds + * glucose withholds the index too. + */ +import type { DoctorReportStats } from "@/lib/doctor-report-types"; +import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index"; +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; + +import type { ReportGate } from "./selection-gate"; + +export async function readReportGki( + gate: ReportGate, + /** The report's own stats: no ketone row in the window, no read. */ + stats: Readonly>, + userId: string, + /** The report window; `end` is inclusive, as the measurement read has it. */ + start: Date, + end: Date, +): Promise { + if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) { + return null; + } + if (!stats.BLOOD_KETONES?.count) return null; + const points = await readGlucoseKetoneIndex({ + userId, + from: start, + to: new Date(end.getTime() + 1), + }); + const summary = summariseGlucoseKetoneIndex(points); + if (!summary) return null; + return { + avg: summary.mean, + min: summary.min, + max: summary.max, + count: summary.count, + latest: summary.latest, + latestAt: points[points.length - 1]!.at, + }; +} diff --git a/src/lib/fhir/__tests__/series.test.ts b/src/lib/fhir/__tests__/series.test.ts index ef832ef0d..5659383b1 100644 --- a/src/lib/fhir/__tests__/series.test.ts +++ b/src/lib/fhir/__tests__/series.test.ts @@ -196,6 +196,32 @@ describe("measurementSeriesObservation", () => { expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc); }); + it("codes blood ketones as beta-hydroxybutyrate in blood (#1367)", () => { + const o = measurementSeriesObservation( + { + id: "k1", + type: "BLOOD_KETONES", + value: 1.4, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.category?.[0]?.coding?.[0]?.code).toBe("laboratory"); + expect(o.code.coding).toEqual([ + { + system: "http://loinc.org", + code: "104816-4", + display: "Beta hydroxybutyrate [Moles/volume] in Blood", + }, + ]); + expect(o.valueQuantity).toMatchObject({ value: 1.4, unit: "mmol/L" }); + }); + it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => { const o = measurementSeriesObservation( { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index f35d6d054..7a3160e6a 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -29,6 +29,7 @@ export const ALLOWED_CHART_TOKENS = [ "metric:SLEEP_DURATION", "metric:ACTIVITY_STEPS", "metric:BLOOD_GLUCOSE", + "metric:BLOOD_KETONES", "metric:TOTAL_BODY_WATER", "metric:BONE_MASS", "metric:OXYGEN_SATURATION", diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 2f761a6bd..5ec96d5b0 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -80,6 +80,8 @@ export type InsightMetric = // `summaries[metric].count > 0` with no new handler branch (the // metric auto-lights-up the moment the first reading lands). | "BLOOD_GLUCOSE" + // #1367 — blood ketones, keyed on its MeasurementType like glucose. + | "BLOOD_KETONES" | "TOTAL_BODY_WATER" | "BONE_MASS" // Body fat percentage, keyed on its MeasurementType like the rest of the diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index c088eabd5..5975e31e4 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -139,6 +139,8 @@ const SUB_PAGE_METRIC = { daylight: ["TIME_IN_DAYLIGHT"], // ── metabolic (v1.7.0) ── "blood-glucose": ["BLOOD_GLUCOSE"], + // #1367 — blood ketones, with the glucose-ketone index beneath the chart. + ketones: ["BLOOD_KETONES"], "skin-temperature": ["SKIN_TEMPERATURE"], // v1.10.0 — overnight wrist temperature joins the metabolic cluster. "wrist-temperature": ["WRIST_TEMPERATURE"], @@ -243,6 +245,7 @@ export const SUB_PAGE_GROUP: Partial> = { daylight: "environment", // metabolic (v1.7.0) "blood-glucose": "metabolic", + ketones: "metabolic", "skin-temperature": "metabolic", // ── v1.10.0 — additive HealthKit signals (WX-A) ── // activity / mobility cluster diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 5e858fd51..cd80e3dd1 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // derivation as PHQ-9 / GAD-7. No HK mapping by design. "WHO5_SCORE", "SCI_SCORE", + // Blood ketones (#1367): HealthKit has no beta-hydroxybutyrate quantity + // type, so a ketone reading is typed by hand. No HK mapping by design. + "BLOOD_KETONES", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts new file mode 100644 index 000000000..2c91dee3c --- /dev/null +++ b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { MGDL_PER_MMOL } from "@/lib/glucose"; +import { + GKI_PAIRING_WINDOW_MINUTES, + glucoseKetoneIndex, + pairGlucoseKetoneIndex, + summariseGlucoseKetoneIndex, + type GkiReading, +} from "@/lib/measurements/glucose-ketone-index"; + +const T0 = Date.parse("2026-11-01T08:00:00.000Z"); +const MIN = 60_000; + +function reading( + id: string, + offsetMin: number, + value: number, + source = "MANUAL", +): GkiReading { + return { id, measuredAt: new Date(T0 + offsetMin * MIN), value, source }; +} + +describe("glucoseKetoneIndex", () => { + it("divides glucose in mmol/L by ketones, converting with the one factor", () => { + // 90 mg/dL = 4.995 mmol/L; over 1.5 mmol/L ketones. + expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(90 / MGDL_PER_MMOL / 1.5); + expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(3.33, 2); + }); + + it("has no index without ketones or with a value that is not positive", () => { + expect(glucoseKetoneIndex(90, 0)).toBeNull(); + expect(glucoseKetoneIndex(90, -1)).toBeNull(); + expect(glucoseKetoneIndex(0, 1)).toBeNull(); + expect(glucoseKetoneIndex(Number.NaN, 1)).toBeNull(); + expect(glucoseKetoneIndex(90, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +describe("pairGlucoseKetoneIndex", () => { + it("pairs a fingerstick written with one timestamp", () => { + const [point, ...rest] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1.2)], + [reading("g1", 0, 81)], + ); + expect(rest).toEqual([]); + expect(point).toEqual({ + ketoneId: "k1", + glucoseId: "g1", + at: new Date(T0).toISOString(), + glucoseAt: new Date(T0).toISOString(), + // 81 / 18.0182 = 4.4954 mmol/L; / 1.2 = 3.746 -> 3.7 + value: 3.7, + ketonesMmol: 1.2, + glucoseMmol: 4.5, + }); + }); + + it("prefers the same instant over a nearer-looking neighbour", () => { + const [point] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-early", -1, 200), reading("g-same", 0, 90)], + ); + expect(point?.glucoseId).toBe("g-same"); + }); + + it("takes the nearest glucose inside the window, before or after", () => { + const [before] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-far", -14, 100), reading("g-near", -3, 90)], + ); + expect(before?.glucoseId).toBe("g-near"); + const [after] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-after", 4, 90), reading("g-before", -9, 100)], + ); + expect(after?.glucoseId).toBe("g-after"); + }); + + it("pairs at the window's edge and never beyond it", () => { + const edge = GKI_PAIRING_WINDOW_MINUTES; + expect( + pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", edge, 90)]), + ).toHaveLength(1); + expect( + pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", -edge, 90)]), + ).toHaveLength(1); + expect( + pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g1", edge + 0.5, 90), reading("g2", -(edge + 1), 90)], + ), + ).toEqual([]); + }); + + it("leaves out a ketone reading of zero or without a partner", () => { + expect( + pairGlucoseKetoneIndex( + [reading("k-zero", 0, 0), reading("k-alone", 120, 1.4)], + [reading("g1", 0, 90)], + ), + ).toEqual([]); + }); + + it("breaks a tie by source, then by the earlier reading", () => { + const [bySource] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1, "MANUAL")], + [reading("g-cgm", 0, 140, "APPLE_HEALTH"), reading("g-own", 0, 90)], + ); + expect(bySource?.glucoseId).toBe("g-own"); + const [byTime] = pairGlucoseKetoneIndex( + [reading("k1", 0, 1)], + [reading("g-later", 5, 100), reading("g-earlier", -5, 90)], + ); + expect(byTime?.glucoseId).toBe("g-earlier"); + }); + + it("answers in ketone order whatever order the readings came in", () => { + const points = pairGlucoseKetoneIndex( + [reading("k2", 60, 2), reading("k1", 0, 1)], + [reading("g2", 61, 90), reading("g1", 1, 90)], + ); + expect(points.map((p) => [p.ketoneId, p.glucoseId])).toEqual([ + ["k1", "g1"], + ["k2", "g2"], + ]); + }); + + it("lets one glucose reading serve two ketone readings and names it", () => { + const points = pairGlucoseKetoneIndex( + [reading("k1", 0, 1), reading("k2", 6, 2)], + [reading("g1", 3, 90)], + ); + expect(points.map((p) => p.glucoseId)).toEqual(["g1", "g1"]); + expect(points[1]!.value).toBeLessThan(points[0]!.value); + }); +}); + +describe("summariseGlucoseKetoneIndex", () => { + it("summarises the indices and is null for none", () => { + expect(summariseGlucoseKetoneIndex([])).toBeNull(); + const points = pairGlucoseKetoneIndex( + [reading("k1", 0, 1), reading("k2", 60, 2), reading("k3", 120, 4)], + [reading("g1", 0, 90), reading("g2", 60, 90), reading("g3", 120, 90)], + ); + // 90 mg/dL = 4.995 mmol/L: indices 5.0, 2.5, 1.2. + expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.2]); + expect(summariseGlucoseKetoneIndex(points)).toEqual({ + count: 3, + mean: 2.9, + min: 1.2, + max: 5, + latest: 1.2, + }); + }); +}); diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 1855bf5f3..910966d5b 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -128,6 +128,7 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Metabolic ── ["BLOOD_GLUCOSE", "metabolic"], + ["BLOOD_KETONES", "metabolic"], ["SKIN_TEMPERATURE", "metabolic"], // v1.10.0 — wrist temperature is an overnight skin-side reading; // it sits with skin temperature in the metabolic cluster. diff --git a/src/lib/measurements/glucose-ketone-index.ts b/src/lib/measurements/glucose-ketone-index.ts new file mode 100644 index 000000000..46bf54dfa --- /dev/null +++ b/src/lib/measurements/glucose-ketone-index.ts @@ -0,0 +1,189 @@ +/** + * The glucose-ketone index (GKI), derived from a ketone reading and the + * glucose reading taken with it (#1367). + * + * GKI = glucose (mmol/L) / beta-hydroxybutyrate (mmol/L) + * + * Glucose is stored in mg/dL and converted with {@link MGDL_PER_MMOL}, the one + * factor the tree uses for that conversion, so the glucose a GKI is built from + * is the same number every glucose surface shows. The index is computed when + * it is read and never stored: a stored ratio drifts the moment either reading + * it came from is edited or deleted, which is the failure a hand-typed GKI has. + * + * Pairing, per ketone reading: + * + * 1. A glucose reading at the same instant wins. A fingerstick entry writes + * both readings with one timestamp, so this is the usual case. + * 2. Otherwise the nearest glucose reading no more than + * {@link GKI_PAIRING_WINDOW_MINUTES} minutes before or after it. + * 3. Nothing further away. A ketone reading with no glucose inside the + * window, or with a value of zero, has no index. + * + * Ties at the same distance go to the reading from the same source as the + * ketone reading (a manual fingerstick pairs with the manual glucose rather + * than with a sensor reading at the same minute), then to the earlier one. One + * glucose reading may serve two ketone readings taken close together; each + * index names the glucose reading it was built from. + * + * The figure is descriptive. Nothing here grades it: what a GKI should be is a + * matter for the plan a clinician sets. + */ +import { MGDL_PER_MMOL } from "@/lib/glucose"; + +/** + * The widest gap between a ketone and a glucose reading that still pairs. + * + * Fifteen minutes covers two strips taken one after the other, a meter that + * reads ketones from a second drop, a reading typed in a few minutes late, and + * a sensor that samples every five minutes, while staying short enough that + * glucose has not moved on: after a meal it can rise by a few mg/dL a minute, + * so an hour-old reading describes a different moment and the ratio built from + * it would describe neither. + */ +export const GKI_PAIRING_WINDOW_MINUTES = 15; + +/** + * The unit both components of the index are in. Fixed, not the reader's + * glucose unit: the index is defined on mmol/L, and a ratio taken over mg/dL + * would be eighteen times larger and mean something else. + */ +export const GKI_COMPONENT_UNIT = "mmol/L"; +export const GKI_PAIRING_WINDOW_MS = GKI_PAIRING_WINDOW_MINUTES * 60_000; + +/** One stored reading, as the pairing needs it. */ +export interface GkiReading { + id: string; + measuredAt: Date; + /** Ketones in mmol/L; glucose in canonical mg/dL. */ + value: number; + source: string; +} + +/** One derived index, with the two readings it was built from. */ +export interface GlucoseKetoneIndexPoint { + /** The ketone reading's id. The index belongs to the ketone reading. */ + ketoneId: string; + /** The glucose reading it was paired with. */ + glucoseId: string; + /** The ketone reading's instant, ISO 8601. */ + at: string; + /** The glucose reading's instant, ISO 8601. */ + glucoseAt: string; + /** The index, to one decimal. */ + value: number; + /** Beta-hydroxybutyrate as recorded, mmol/L. */ + ketonesMmol: number; + /** The paired glucose in mmol/L, to one decimal. */ + glucoseMmol: number; +} + +/** + * The index of one glucose (mg/dL) and one ketone (mmol/L) value, unrounded, + * or null when it is undefined: no ketones, or a value that is not a positive + * finite number on either side. + */ +export function glucoseKetoneIndex( + glucoseMgdl: number, + ketonesMmol: number, +): number | null { + if (!Number.isFinite(glucoseMgdl) || !Number.isFinite(ketonesMmol)) { + return null; + } + if (ketonesMmol <= 0 || glucoseMgdl <= 0) return null; + return glucoseMgdl / MGDL_PER_MMOL / ketonesMmol; +} + +function oneDecimal(value: number): number { + return Math.round(value * 10) / 10; +} + +/** + * Pair every ketone reading with its glucose reading and compute the index. + * Pure. Both lists may come in any order; the answer is in ketone-reading + * order (oldest first). Ketone readings without a partner are left out. + */ +export function pairGlucoseKetoneIndex( + ketones: readonly GkiReading[], + glucose: readonly GkiReading[], +): GlucoseKetoneIndexPoint[] { + const sortedGlucose = [...glucose].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const sortedKetones = [...ketones].sort( + (a, b) => + a.measuredAt.getTime() - b.measuredAt.getTime() || + a.id.localeCompare(b.id), + ); + const out: GlucoseKetoneIndexPoint[] = []; + let lo = 0; + for (const ketone of sortedKetones) { + if (!(ketone.value > 0)) continue; + const at = ketone.measuredAt.getTime(); + // Ketone readings are walked in time order, so the window's lower edge + // only ever moves forward. + while ( + lo < sortedGlucose.length && + sortedGlucose[lo]!.measuredAt.getTime() < at - GKI_PAIRING_WINDOW_MS + ) { + lo++; + } + let best: GkiReading | null = null; + let bestRank: [number, number, number] | null = null; + for (let i = lo; i < sortedGlucose.length; i++) { + const candidate = sortedGlucose[i]!; + const t = candidate.measuredAt.getTime(); + if (t > at + GKI_PAIRING_WINDOW_MS) break; + const rank: [number, number, number] = [ + Math.abs(t - at), + candidate.source === ketone.source ? 0 : 1, + t, + ]; + if ( + bestRank === null || + rank[0] < bestRank[0] || + (rank[0] === bestRank[0] && + (rank[1] < bestRank[1] || + (rank[1] === bestRank[1] && rank[2] < bestRank[2]))) + ) { + best = candidate; + bestRank = rank; + } + } + if (best === null) continue; + const index = glucoseKetoneIndex(best.value, ketone.value); + if (index === null) continue; + out.push({ + ketoneId: ketone.id, + glucoseId: best.id, + at: ketone.measuredAt.toISOString(), + glucoseAt: best.measuredAt.toISOString(), + value: oneDecimal(index), + ketonesMmol: ketone.value, + glucoseMmol: oneDecimal(best.value / MGDL_PER_MMOL), + }); + } + return out; +} + +/** Count, mean, min, max and latest of a run of indices, or null when empty. */ +export interface GlucoseKetoneIndexSummary { + count: number; + mean: number; + min: number; + max: number; + latest: number; +} + +export function summariseGlucoseKetoneIndex( + points: readonly GlucoseKetoneIndexPoint[], +): GlucoseKetoneIndexSummary | null { + if (points.length === 0) return null; + const values = points.map((p) => p.value); + return { + count: values.length, + mean: oneDecimal(values.reduce((a, b) => a + b, 0) / values.length), + min: Math.min(...values), + max: Math.max(...values), + latest: values[values.length - 1]!, + }; +} diff --git a/src/lib/measurements/read-glucose-ketone-index.ts b/src/lib/measurements/read-glucose-ketone-index.ts new file mode 100644 index 000000000..cc3afe988 --- /dev/null +++ b/src/lib/measurements/read-glucose-ketone-index.ts @@ -0,0 +1,86 @@ +/** + * Read the glucose-ketone index of a window (#1367): the ketone readings in + * it and the glucose readings within pairing reach of one, paired by + * `pairGlucoseKetoneIndex`. Every surface that shows the index (the series + * route, the day, the doctor report) reads it here, so the pairing rule has + * one implementation. + * + * Glucose and ketones carry no source ladder (neither is in `RANKED_TYPES`), + * so every live row of every source is a candidate, as on the glucose chart. + * The glucose read is bounded by the ketone readings: only rows within the + * pairing window of one are fetched, so a sensor writing every five minutes + * contributes a handful of rows per fingerstick, not the window's thousands. + */ +import { prisma } from "@/lib/db"; + +import { + GKI_PAIRING_WINDOW_MINUTES, + GKI_PAIRING_WINDOW_MS, + pairGlucoseKetoneIndex, + type GkiReading, + type GlucoseKetoneIndexPoint, +} from "./glucose-ketone-index"; + +interface Row { + id: string; + value: number; + measured_at: Date; + source: string; +} + +function toReading(row: Row): GkiReading { + return { + id: row.id, + value: row.value, + measuredAt: row.measured_at, + source: row.source, + }; +} + +/** + * The indices of the ketone readings taken in `[from, to)`, oldest first. + * A glucose reading just outside the window still pairs with a ketone reading + * just inside it. + */ +export async function readGlucoseKetoneIndex(args: { + userId: string; + from: Date; + to: Date; +}): Promise { + const { userId, from, to } = args; + const ketones = await prisma.$queryRaw` + SELECT k."id", k."value", k."measured_at", k."source"::text AS "source" + FROM measurements k + WHERE k."user_id" = ${userId} + AND k."type" = 'BLOOD_KETONES'::"measurement_type" + AND k."deleted_at" IS NULL + AND k."measured_at" >= ${from} + AND k."measured_at" < ${to} + AND k."value" > 0 + ORDER BY k."measured_at" ASC, k."id" ASC + `; + if (ketones.length === 0) return []; + // An interval between two stored instants: no zone enters the arithmetic. + const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`; + const glucose = await prisma.$queryRaw` + SELECT g."id", g."value", g."measured_at", g."source"::text AS "source" + FROM measurements g + WHERE g."user_id" = ${userId} + AND g."type" = 'BLOOD_GLUCOSE'::"measurement_type" + AND g."deleted_at" IS NULL + AND g."measured_at" >= ${new Date(from.getTime() - GKI_PAIRING_WINDOW_MS)} + AND g."measured_at" <= ${new Date(to.getTime() + GKI_PAIRING_WINDOW_MS)} + AND EXISTS ( + SELECT 1 FROM measurements k + WHERE k."user_id" = ${userId} + AND k."type" = 'BLOOD_KETONES'::"measurement_type" + AND k."deleted_at" IS NULL + AND k."measured_at" >= ${from} + AND k."measured_at" < ${to} + AND k."value" > 0 + AND k."measured_at" BETWEEN g."measured_at" - ${reach}::interval + AND g."measured_at" + ${reach}::interval + ) + `; + return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading)); +} diff --git a/src/lib/measurements/series-reference-lines.ts b/src/lib/measurements/series-reference-lines.ts index 0915c3b2d..9693e7e81 100644 --- a/src/lib/measurements/series-reference-lines.ts +++ b/src/lib/measurements/series-reference-lines.ts @@ -44,7 +44,9 @@ export type SeriesKind = | "oxygenSaturation" | "restingHeartRate" | "heartRateVariability" - | "vo2Max"; + | "vo2Max" + | "ketones" + | "glucoseKetoneIndex"; export interface SeriesReferenceLine { value: number; diff --git a/src/lib/measurements/series/kinds.ts b/src/lib/measurements/series/kinds.ts index e9a1f76cc..c4756ff70 100644 --- a/src/lib/measurements/series/kinds.ts +++ b/src/lib/measurements/series/kinds.ts @@ -72,6 +72,10 @@ export const kindEnum = z.enum([ "restingHeartRate", "heartRateVariability", "vo2Max", + // #1367 — blood ketones, and the glucose-ketone index derived from them and + // the glucose reading taken with each (`glucose-ketone-index.ts`). + "ketones", + "glucoseKetoneIndex", ]); export type SeriesKind = z.infer; @@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record = { restingHeartRate: "RESTING_HEART_RATE", heartRateVariability: "HEART_RATE_VARIABILITY", vo2Max: "VO2_MAX", + ketones: "BLOOD_KETONES", + // The index has no stored type. It belongs to its ketone reading, so the + // change direction and day values read as the ketone series' would; neither + // has a band, so nothing is judged. + glucoseKetoneIndex: "BLOOD_KETONES", }; /** @@ -119,6 +128,9 @@ export const SERIES_UNIT: Record = { restingHeartRate: "bpm", heartRateVariability: "ms", vo2Max: "mL/(kg·min)", + ketones: "mmol/L", + // A ratio of two concentrations in the same unit: dimensionless. + glucoseKetoneIndex: "ratio", }; export interface SeriesPoint { @@ -142,6 +154,17 @@ export interface SeriesPoint { */ valueMin?: number | null; valueMax?: number | null; + /** + * #1367 — the two readings a `kind=glucoseKetoneIndex` point was built + * from. Absent for every other kind. `id` and `at` are the ketone + * reading's; this names the glucose reading paired with it. + */ + gki?: { + glucoseId: string; + glucoseAt: string; + glucoseMmol: number; + ketonesMmol: number; + }; } export function stdDev(values: number[]): number { diff --git a/src/lib/measurements/series/read-glucose-ketone-index.ts b/src/lib/measurements/series/read-glucose-ketone-index.ts new file mode 100644 index 000000000..a34c50a03 --- /dev/null +++ b/src/lib/measurements/series/read-glucose-ketone-index.ts @@ -0,0 +1,37 @@ +/** + * `kind=glucoseKetoneIndex`: one point per ketone reading that pairs with a + * glucose reading (#1367). The index is derived at read time by the one + * pairing rule (`read-glucose-ketone-index.ts`); a client draws the value it + * is given and never recomputes it. Ketone readings are a few a day at most, + * so the window is never bucketed. + */ +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; + +import type { SeriesRead } from "./kinds"; + +export async function readGlucoseKetoneIndexSeries( + userId: string, + since: Date, +): Promise { + const points = await readGlucoseKetoneIndex({ + userId, + from: since, + // Open-ended: a reading stamped a moment ahead of the server clock is + // still the latest one. + to: new Date(Date.now() + 86_400_000), + }); + return { + points: points.map((p) => ({ + id: p.ketoneId, + at: p.at, + value: p.value, + secondary: null, + gki: { + glucoseId: p.glucoseId, + glucoseAt: p.glucoseAt, + glucoseMmol: p.glucoseMmol, + ketonesMmol: p.ketonesMmol, + }, + })), + }; +} diff --git a/src/lib/measurements/series/read-series.ts b/src/lib/measurements/series/read-series.ts index 3061619ea..0608bc699 100644 --- a/src/lib/measurements/series/read-series.ts +++ b/src/lib/measurements/series/read-series.ts @@ -28,6 +28,7 @@ import { } from "./kinds"; import { readBloodPressureSeries } from "./read-blood-pressure"; import { readBucketedSeries, takesBucketedRead } from "./read-bucketed"; +import { readGlucoseKetoneIndexSeries } from "./read-glucose-ketone-index"; import { readRawSeries } from "./read-raw"; import { readSleepSeries } from "./read-sleep"; @@ -40,6 +41,9 @@ async function readPoints( ): Promise { if (kind === "sleep") return readSleepSeries(userId, since, days); if (kind === "bloodPressure") return readBloodPressureSeries(userId, since); + if (kind === "glucoseKetoneIndex") { + return readGlucoseKetoneIndexSeries(userId, since); + } if (await takesBucketedRead(userId, kind, days, since)) { return readBucketedSeries(userId, kind, days, since); } diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index b8d7315e8..02699e16e 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { SLEEP_DURATION: "measurements.typeSleep", ACTIVITY_STEPS: "measurements.typeSteps", BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + BLOOD_KETONES: "measurements.typeBloodKetones", TOTAL_BODY_WATER: "measurements.typeTotalBodyWater", BONE_MASS: "measurements.typeBoneMass", OXYGEN_SATURATION: "measurements.typeOxygenSaturation", diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index a9eef07f9..117b54d0f 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -21,6 +21,7 @@ const unitMap: Record = { SLEEP_DURATION: "minutes", ACTIVITY_STEPS: "steps", BLOOD_GLUCOSE: "mg/dL", + BLOOD_KETONES: "mmol/L", TOTAL_BODY_WATER: "kg", BONE_MASS: "kg", OXYGEN_SATURATION: "%", diff --git a/src/lib/measurements/value-ranges.ts b/src/lib/measurements/value-ranges.ts index f03dd6b96..41f5af2e8 100644 --- a/src/lib/measurements/value-ranges.ts +++ b/src/lib/measurements/value-ranges.ts @@ -20,6 +20,12 @@ export const VALUE_RANGES: Record = { SLEEP_DURATION: { min: 0, max: 1440 }, ACTIVITY_STEPS: { min: 0, max: 200000 }, BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia + // Beta-hydroxybutyrate, mmol/L. The blood ketone meters people type these + // off (Abbott Precision Xtra / FreeStyle Optium, Keto-Mojo, Nova Max Plus) + // measure from 0.0 or 0.1 up to 8.0 mmol/L and print "HI" above it; 10 + // leaves room for a venous value read off a lab sheet. A meter reads 0.0, + // so zero is a real reading, not an empty one. + BLOOD_KETONES: { min: 0, max: 10 }, TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 689b00085..122ba1757 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -105,6 +105,10 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = STRESS_LEVEL: "recovery", TRAINING_READINESS: "recovery", CARDIO_RECOVERY: "recovery", + // Blood ketones are read off the same meter as fingerstick glucose and + // belong to the glucose module; they stay off the Coach snapshot, so the + // scope token cannot reach them. + BLOOD_KETONES: "glucose", // The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD // fallback for an account that only has ring/strap nightly RMSSD rows, so // leaving this unowned would have let the fallback path serve exactly the diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index b70d84516..3463dea26 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -98,6 +98,7 @@ const STATIC_SURFACE_MODULE = { // The per-night breathing-disturbance index is a sleep-page signal. "insights-page:breathing-disturbances": "sleep", "insights-page:blood-glucose": "glucose", + "insights-page:ketones": "glucose", "insights-page:workouts": "workouts", "insights-page:nutrients": "nutrients", // Medications is surface-gated by design: its data routes stay open so an @@ -160,6 +161,8 @@ const STATIC_SURFACE_MODULE = { // ── Dashboard summary keys ── "summary:SLEEP_DURATION": "sleep", "summary:BLOOD_GLUCOSE": "glucose", + // Blood ketones sit beside glucose: same meter, same module. + "summary:BLOOD_KETONES": "glucose", "summary:CARDIO_RECOVERY": "recovery", // The recovery module owns the resting_hr domain (`measurement-scope.ts`), // so the server gates the type on it; the form and the dashboard follow. diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 543754f5c..ef61f50af 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -696,6 +696,8 @@ const seriesKindEnum = z.enum([ "restingHeartRate", "heartRateVariability", "vo2Max", + "ketones", + "glucoseKetoneIndex", ]); const seriesQuerySchema = z.object({ @@ -747,6 +749,23 @@ const seriesPointSchema = z.object({ .describe( "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.", ), + gki: z + .object({ + glucoseId: z.string().describe("The paired glucose reading's id."), + glucoseAt: isoDateTime().describe( + "The paired glucose reading's timestamp.", + ), + glucoseMmol: z + .number() + .describe("The paired glucose in mmol/L, one decimal."), + ketonesMmol: z + .number() + .describe("The ketone reading (beta-hydroxybutyrate), mmol/L."), + }) + .optional() + .describe( + "#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are the ketone reading's. Absent for every other kind.", + ), }); const seriesResponse = z @@ -755,7 +774,7 @@ const seriesResponse = z unit: z .string() .describe( - "Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).", + "Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.", ), points: z.array(seriesPointSchema), stats: z @@ -783,7 +802,7 @@ const seriesResponse = z .meta({ id: "MeasurementsSeriesResponse", description: - "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.", + "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it.", }); // ── Batched daily series (v1.18.6 dashboard fetch coalescing) ──────── diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 0281e5f2c..96dd6ab0a 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -175,10 +175,11 @@ export function getPRDirection( if (context.weightGoal === "higher") return PersonalRecordDirection.MAX; return null; - // Explicitly no PersonalRecord — see comment block above. + // Explicitly no PersonalRecord (see above); ketones follow a clinician's plan. case "BLOOD_PRESSURE_SYS": case "BLOOD_PRESSURE_DIA": case "BLOOD_GLUCOSE": + case "BLOOD_KETONES": case "BODY_TEMPERATURE": case "SKIN_TEMPERATURE": case "PULSE": @@ -267,9 +268,8 @@ export function getPRDirection( case "TRAINING_READINESS": case "ACUTE_TRAINING_LOAD": // v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite - // (like the other sleep-quality indices), and the body-temperature - // deviation is a homeostatic signed offset centred on 0 — neither has a - // goal axis, so both stay null like the rest of the derived/homeostatic set. + // and the body-temperature deviation a homeostatic signed offset centred + // on 0: neither has a goal axis, so both stay null like the set above. case "SLEEP_SCORE": case "BODY_TEMPERATURE_DEVIATION": // v1.19.0 — Oura resilience. An ordinal-encoded categorical band diff --git a/src/lib/queries/use-glucose-ketone-index.ts b/src/lib/queries/use-glucose-ketone-index.ts new file mode 100644 index 000000000..a724cee56 --- /dev/null +++ b/src/lib/queries/use-glucose-ketone-index.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +/** One point of `kind=glucoseKetoneIndex`, as the server resolved it. */ +export interface GlucoseKetoneIndexSeriesPoint { + /** The ketone reading's id. */ + id: string; + /** The ketone reading's instant. */ + at: string; + /** The index, one decimal. */ + value: number; + gki: { + glucoseId: string; + glucoseAt: string; + glucoseMmol: number; + ketonesMmol: number; + }; +} + +interface GlucoseKetoneIndexSeries { + kind: "glucoseKetoneIndex"; + unit: string; + points: GlucoseKetoneIndexSeriesPoint[]; +} + +/** The whole history the series route serves; the chart narrows it. */ +export const GKI_SERIES_DAYS = 3650; + +/** + * The glucose-ketone index over the last {@link GKI_SERIES_DAYS} days (#1367). + * The server pairs the readings and computes every index; the card draws them + * as given. + */ +export function useGlucoseKetoneIndexSeries() { + const { isAuthenticated } = useAuth(); + return useQuery({ + queryKey: queryKeys.glucoseKetoneIndexSeries(GKI_SERIES_DAYS), + queryFn: () => + apiGet( + `/api/measurements/series?kind=glucoseKetoneIndex&days=${GKI_SERIES_DAYS}`, + ), + staleTime: 60_000, + enabled: isAuthenticated, + }); +} diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index 3ff5b167d..6b9215f75 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -127,6 +127,14 @@ export const measurementKeys = { bpTimeOfDay: (days: number) => ["measurements", "bp-time-of-day", days] as const, + /** + * #1367 — the glucose-ketone index series + * (`GET /api/measurements/series?kind=glucoseKetoneIndex&days=`). Under the + * `["measurements"]` prefix, so a new glucose or ketone reading refreshes it. + */ + glucoseKetoneIndexSeries: (days: number) => + ["measurements", "glucose-ketone-index", days] as const, + /** * v1.8.5 — bounded recent-timestamp read powering the * measurement-diversity nudge on an insights category page. Keyed by diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index b46c8b72b..a89342313 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -63,9 +63,9 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { - expect(MEASUREMENT_LEAF_IDS).toHaveLength(91); + expect(MEASUREMENT_LEAF_IDS).toHaveLength(92); expect(STRUCTURED_LEAF_IDS).toHaveLength(20); - expect(ALL_LEAF_IDS).toHaveLength(111); + expect(ALL_LEAF_IDS).toHaveLength(112); }); it("resolves a label for every leaf", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index 69e8c001b..611b9ca24 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -197,6 +197,7 @@ export const MEASUREMENT_LEAF_GROUP: Record = { // ── glucose ── BLOOD_GLUCOSE: "glucose", + BLOOD_KETONES: "glucose", // ── fenced ── // Mindful minutes and the State of Mind valence are mental-health @@ -337,6 +338,7 @@ export const LEAF_MODULE: Partial> = { INDOOR_HUMIDITY: "environment", // Glucose: the raw series and the derived panel. BLOOD_GLUCOSE: "glucose", + BLOOD_KETONES: "glucose", GLUCOSE_PANEL: "glucose", // The screening totals ride the mental-health module as well as being // fenced in the panel. Item-level answers are never a Measurement, so only diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 6a09e748b..eac3adf63 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -328,5 +328,11 @@ "display": "Indoor relative humidity", "unit": "%", "category": "activity" + }, + "BLOOD_KETONES": { + "loinc": "104816-4", + "display": "Beta hydroxybutyrate [Moles/volume] in Blood", + "unit": "mmol/L", + "category": "laboratory" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index d807b5c25..88f6359eb 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -341,6 +341,35 @@ export const SIGNALS: Record = { mcp: true, }, }, + // Blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick meter, + // typed by hand. No band: what a ketone reading should be depends on the + // plan a clinician sets, so the value is shown as recorded. Off the Coach + // snapshot and the correlations, readable over MCP. + BLOOD_KETONES: { + key: "BLOOD_KETONES", + kind: "measurement", + source: { measurementType: "BLOOD_KETONES" }, + displayName: "Blood ketones (BHB)", + unit: "mmol/L", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + // LOINC 104816-4 "Beta hydroxybutyrate [Moles/volume] in Blood": the + // quantitative, method-less blood term. A typed value does not say + // whether it came from a strip or a venous draw, and the strip term + // (66441-7) is semi-quantitative, which a numeric mmol/L reading is not. + fhir: { + loinc: "104816-4", + display: "Beta hydroxybutyrate [Moles/volume] in Blood", + unit: "mmol/L", + category: "laboratory", + }, + }, WALKING_HEART_RATE_AVERAGE: { key: "WALKING_HEART_RATE_AVERAGE", kind: "measurement", diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index df2b1b383..216d52bf6 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -171,6 +171,8 @@ export const measurementTypeEnum = z.enum([ "ACUTE_TRAINING_LOAD", // ── resting energy (#1340), HealthKit `basalEnergyBurned` ── "BASAL_ENERGY_BURNED", + // ── blood ketones (#1367), beta-hydroxybutyrate in mmol/L ── + "BLOOD_KETONES", ]); /** diff --git a/tests/integration/__golden__/backup-parity/refusals.json b/tests/integration/__golden__/backup-parity/refusals.json index 356704fd6..570e213cb 100644 --- a/tests/integration/__golden__/backup-parity/refusals.json +++ b/tests/integration/__golden__/backup-parity/refusals.json @@ -68,7 +68,7 @@ "backupId": "", "ownerId": "round-trip-owner", "reason": "schema_invalid", - "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\"|\"BASAL_ENERGY_BURNED\"" + "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\"|\"BASAL_ENERGY_BURNED\"|\"BLOOD_KETONES\"" } } ], diff --git a/tests/integration/blood-ketones-gki.test.ts b/tests/integration/blood-ketones-gki.test.ts new file mode 100644 index 000000000..fccf01eda --- /dev/null +++ b/tests/integration/blood-ketones-gki.test.ts @@ -0,0 +1,339 @@ +/** + * Blood ketones and the glucose-ketone index (#1367), against real Postgres. + * + * A fingerstick posts its glucose and ketones as one array: both rows land + * with one timestamp, or neither does. The index is derived at read time by + * one pairing rule and reaches the series route, the day and the doctor + * report with the same numbers; a ketone reading without a glucose reading + * within 15 minutes, or with a value of zero, has none. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function signIn(): Promise { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "ketones-owner", + email: "ketones-owner@example.test", + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner.id; +} + +function post(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const MIN = 60_000; +/** A recent morning, inside every default read window. */ +const T0 = new Date(Date.now() - 2 * 86_400_000); +T0.setUTCHours(8, 0, 0, 0); +const at = (offsetMin: number) => new Date(T0.getTime() + offsetMin * MIN); + +async function seed( + userId: string, + rows: Array<{ + type: "BLOOD_GLUCOSE" | "BLOOD_KETONES"; + value: number; + offsetMin: number; + }>, +) { + await getPrismaClient().measurement.createMany({ + data: rows.map((r) => ({ + userId, + type: r.type, + value: r.value, + unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "mmol/L", + source: "MANUAL" as const, + measuredAt: at(r.offsetMin), + glucoseContext: r.type === "BLOOD_GLUCOSE" ? ("FASTING" as const) : null, + })), + }); +} + +/** + * Four fingersticks: an exact pair, a pair ten minutes apart, a ketone + * reading whose glucose is twenty minutes away, and a ketone reading of 0. + */ +async function seedMorning(userId: string) { + await seed(userId, [ + { type: "BLOOD_GLUCOSE", value: 81, offsetMin: 0 }, + { type: "BLOOD_KETONES", value: 1.2, offsetMin: 0 }, + { type: "BLOOD_GLUCOSE", value: 90, offsetMin: 130 }, + { type: "BLOOD_KETONES", value: 2, offsetMin: 120 }, + { type: "BLOOD_GLUCOSE", value: 100, offsetMin: 260 }, + { type: "BLOOD_KETONES", value: 1.5, offsetMin: 240 }, + { type: "BLOOD_GLUCOSE", value: 95, offsetMin: 360 }, + { type: "BLOOD_KETONES", value: 0, offsetMin: 360 }, + ]); +} + +describe("fingerstick entry (#1367)", () => { + it("writes the glucose and the ketones together with one timestamp", async () => { + const userId = await signIn(); + const { POST } = await import("@/app/api/measurements/route"); + const measuredAt = at(0).toISOString(); + const res = await POST( + post([ + { + type: "BLOOD_GLUCOSE", + value: 81, + measuredAt, + glucoseContext: "FASTING", + }, + { type: "BLOOD_KETONES", value: 1.2, measuredAt }, + ]), + ); + expect(res.status).toBe(201); + const rows = await getPrismaClient().measurement.findMany({ + where: { userId }, + orderBy: { type: "asc" }, + select: { type: true, value: true, unit: true, measuredAt: true }, + }); + expect(rows).toEqual([ + { type: "BLOOD_GLUCOSE", value: 81, unit: "mg/dL", measuredAt: at(0) }, + { type: "BLOOD_KETONES", value: 1.2, unit: "mmol/L", measuredAt: at(0) }, + ]); + }); + + it("writes neither reading when the ketones are out of range", async () => { + const userId = await signIn(); + const { POST } = await import("@/app/api/measurements/route"); + const measuredAt = at(0).toISOString(); + const res = await POST( + post([ + { + type: "BLOOD_GLUCOSE", + value: 81, + measuredAt, + glucoseContext: "FASTING", + }, + { type: "BLOOD_KETONES", value: 12, measuredAt }, + ]), + ); + expect(res.status).toBe(422); + expect( + await getPrismaClient().measurement.count({ where: { userId } }), + ).toBe(0); + }); + + it("rolls the ketones back when the glucose half is a duplicate", async () => { + const userId = await signIn(); + const { POST } = await import("@/app/api/measurements/route"); + const measuredAt = at(0).toISOString(); + const glucose = { + type: "BLOOD_GLUCOSE", + value: 81, + measuredAt, + glucoseContext: "FASTING", + }; + expect((await POST(post(glucose))).status).toBe(201); + const res = await POST( + post([glucose, { type: "BLOOD_KETONES", value: 1.2, measuredAt }]), + ); + expect(res.status).toBe(409); + expect( + await getPrismaClient().measurement.count({ + where: { userId, type: "BLOOD_KETONES" }, + }), + ).toBe(0); + }); +}); + +describe("glucose-ketone index (#1367)", () => { + it("pairs by the one rule and leaves out what has no partner", async () => { + const userId = await signIn(); + await seedMorning(userId); + const points = await readGlucoseKetoneIndex({ + userId, + from: at(-60), + to: at(24 * 60), + }); + expect( + points.map((p) => ({ + at: p.at, + glucoseAt: p.glucoseAt, + value: p.value, + ketonesMmol: p.ketonesMmol, + glucoseMmol: p.glucoseMmol, + })), + ).toEqual([ + // 81 mg/dL = 4.5 mmol/L; / 1.2 = 3.7 + { + at: at(0).toISOString(), + glucoseAt: at(0).toISOString(), + value: 3.7, + ketonesMmol: 1.2, + glucoseMmol: 4.5, + }, + // 90 mg/dL = 5.0 mmol/L, ten minutes later; / 2 = 2.5 + { + at: at(120).toISOString(), + glucoseAt: at(130).toISOString(), + value: 2.5, + ketonesMmol: 2, + glucoseMmol: 5, + }, + ]); + }); + + it("pairs a glucose reading just outside the window with a ketone reading just inside it", async () => { + const userId = await signIn(); + await seed(userId, [ + { type: "BLOOD_GLUCOSE", value: 90, offsetMin: -5 }, + { type: "BLOOD_KETONES", value: 1, offsetMin: 0 }, + ]); + const points = await readGlucoseKetoneIndex({ + userId, + from: at(0), + to: at(60), + }); + expect(points.map((p) => p.glucoseAt)).toEqual([at(-5).toISOString()]); + }); + + it("serves the index and the ketones on the series route", async () => { + await signIn().then(seedMorning); + const { GET } = await import("@/app/api/measurements/series/route"); + const gki = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=glucoseKetoneIndex&days=30", + ), + ); + expect(gki.status).toBe(200); + const gkiBody = (await gki.json()) as { + data: { + unit: string; + points: Array<{ + value: number; + gki: { glucoseAt: string; glucoseMmol: number; ketonesMmol: number }; + }>; + stats: { count: number }; + referenceLines: unknown[]; + }; + }; + expect(gkiBody.data.unit).toBe("ratio"); + expect(gkiBody.data.points.map((p) => p.value)).toEqual([3.7, 2.5]); + expect(gkiBody.data.points[1]!.gki).toMatchObject({ + glucoseAt: at(130).toISOString(), + glucoseMmol: 5, + ketonesMmol: 2, + }); + expect(gkiBody.data.stats.count).toBe(2); + expect(gkiBody.data.referenceLines).toEqual([]); + + const ketones = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=ketones&days=30", + ), + ); + expect(ketones.status).toBe(200); + const ketoneBody = (await ketones.json()) as { + data: { unit: string; points: Array<{ value: number }> }; + }; + expect(ketoneBody.data.unit).toBe("mmol/L"); + expect(ketoneBody.data.points.map((p) => p.value)).toEqual([ + 1.2, 2, 1.5, 0, + ]); + }); + + it("carries the day's index on the day", async () => { + await signIn().then(seedMorning); + const { GET } = await import("@/app/api/day/[date]/route"); + const date = at(0).toISOString().slice(0, 10); + const res = await GET( + new Request(`http://localhost/api/day/${date}`) as never, + { params: Promise.resolve({ date }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + glucoseKetoneIndex?: Array<{ value: number; glucoseAt: string }>; + values: Array<{ type: string }>; + }; + }; + expect(body.data.glucoseKetoneIndex?.map((p) => p.value)).toEqual([ + 3.7, 2.5, + ]); + expect(body.data.values.some((v) => v.type === "BLOOD_KETONES")).toBe(true); + }); + + it("puts the index in the doctor report only beside both readings", async () => { + const userId = await signIn(); + await seedMorning(userId); + const range = { + start: at(-24 * 60), + end: at(24 * 60), + days: 2, + }; + const both = await collectDoctorReportData( + userId, + range, + selectionFromLeaves(["BLOOD_KETONES", "BLOOD_GLUCOSE"]), + ); + expect(both.stats.BLOOD_KETONES?.count).toBe(4); + expect(both.glucoseKetoneIndex).toMatchObject({ + count: 2, + latest: 2.5, + min: 2.5, + max: 3.7, + avg: 3.1, + }); + + const ketonesOnly = await collectDoctorReportData( + userId, + range, + selectionFromLeaves(["BLOOD_KETONES"]), + ); + expect(ketonesOnly.stats.BLOOD_KETONES?.count).toBe(4); + expect(ketonesOnly.glucoseKetoneIndex).toBeNull(); + }); +}); diff --git a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap index 6f0a8bc35..2ea860c20 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1484,7 +1498,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap index fd8eae42c..a385f18d4 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1484,7 +1498,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap index 20b1ec632..b3d4ec093 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2500,7 +2514,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap index 070bee47c..d27c3b269 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap @@ -1357,6 +1357,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2500,7 +2514,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap index e05ff02a7..59d1df415 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap @@ -1277,6 +1277,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BLOOD_KETONES": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1647,7 +1661,8 @@ "STRESS_LEVEL": null, "TRAINING_READINESS": null, "ACUTE_TRAINING_LOAD": null, - "BASAL_ENERGY_BURNED": null + "BASAL_ENERGY_BURNED": null, + "BLOOD_KETONES": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap index 4287a32ba..a7006e51b 100644 --- a/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurement-series.09-invalid.json.snap @@ -8,7 +8,7 @@ { "path": "kind", "code": "invalid_value", - "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"" + "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"|\"ketones\"|\"glucoseKetoneIndex\"" }, { "path": "days", @@ -39,7 +39,7 @@ { "path": "kind", "code": "invalid_value", - "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"" + "message": "Invalid option: expected one of \"weight\"|\"bloodPressure\"|\"pulse\"|\"bodyFat\"|\"glucose\"|\"sleep\"|\"steps\"|\"totalBodyWater\"|\"boneMass\"|\"oxygenSaturation\"|\"restingHeartRate\"|\"heartRateVariability\"|\"vo2Max\"|\"ketones\"|\"glucoseKetoneIndex\"" }, { "path": "days", From c62abad965300415e6f595df6f1729e34aef742a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:49:30 +0100 Subject: [PATCH 09/46] fix(sync): give every drained domain a fresh watermark two minutes back A domain with no new rows kept its last row's stamp, so once that was older than the tombstone horizon every pull answered cursorExpired and the client resynced from scratch on every pull. A domain whose page runs dry now takes serverNow minus the overlap on every page where its watermark has not passed that line, and on the last page every row domain does, raising a quiet domain and holding back one that delivered rows in the stretch. A domain drained early in a long walk is covered as well. The overlap grows from 60 to 120 seconds, above the 60-second timeout of the longest transactions that write served rows. --- CHANGELOG.md | 22 +++-- docs/api/openapi.yaml | 15 ++-- .../sync/changes/__tests__/start-now.test.ts | 6 +- src/app/api/sync/changes/route.ts | 14 ++-- src/lib/openapi/routes/sync.ts | 4 +- src/lib/sync/__tests__/cursor.test.ts | 6 +- src/lib/sync/changes/pull.ts | 38 ++++++++- src/lib/sync/cursor.ts | 54 ++++++------- .../sync-changes.01-first-page.json.snap | 2 +- .../sync-changes.02-second-page.json.snap | 2 +- .../sync-changes.03-the-rest.json.snap | 2 +- .../sync-changes.04-caught-up.json.snap | 2 +- .../sync-changes.06-before-reset.json.snap | 2 +- .../sync-changes.07-cycle-off.json.snap | 2 +- .../sync-changes-start-now.test.ts | 4 +- tests/integration/sync-changes.test.ts | 80 +++++++++++++++++++ 16 files changed, 186 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd0d97d17..cba2145eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,14 +63,20 @@ the plain unit. Any other text in brackets still counts as part of the unit. (#1313) - **The sync feed no longer loses a reading written during a long - import.** A Garmin or Withings import writes a batch in one transaction - and stamps its readings before it commits them. When a reading logged by - hand committed in between and the app synced right then, the feed's - position moved past the import's readings, and they never reached the - app or Apple Health. A caught-up page of `GET /api/sync/changes` now - hands back a position a minute behind the present, like `start=now` - already does, so the next pull reads that minute again and anything - that committed late comes with it. + import, and no longer starts over for a quiet account.** A Garmin or + Withings import writes a batch in one transaction and stamps its + readings before it commits them. When a reading logged by hand + committed in between and the app synced right then, the feed's position + moved past the import's readings, and they never reached the app or + Apple Health. Separately, a kind of data that had not changed for more + than 75 days (mood, say, for someone who stopped logging it) kept its + old position in the feed, and from then on every sync was answered as + expired and the app started over from scratch, each time. Now every + kind of data that `GET /api/sync/changes` has fully caught up takes a + position two minutes behind the present, like `start=now` (which moves + from one minute to two): what changed in those two minutes is read + once more, so anything that committed late still arrives, and a quiet + kind of data never ages out. - **Changing the calendar subscription while its link is being renewed.** A subscription turned off at the same moment its link was renewed could come back under the new link, and an option changed at that moment diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ab5b2953d..f00512ce5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4522,17 +4522,17 @@ paths: - in: query name: start schema: - description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before + description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits two minutes before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields - what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` + what changes from then on, plus anything written in those two minutes (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row." type: string const: now - description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before + description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits two minutes before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what - changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can + changes from then on, plus anything written in those two minutes (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row." @@ -50202,9 +50202,10 @@ components: - "null" hasMore: type: boolean - description: False once the client is caught up as of `serverNow`. The cursor of such a page sits at most a minute - before `serverNow` in each row domain, so the next pull reads rows changed in that last minute again (a - write still in flight may commit a row stamped there); upserts are idempotent. + description: False once the client is caught up as of `serverNow`. Every row domain that ran dry on a page takes a + watermark two minutes before `serverNow` (on the last page of a walk, all of them), so the next pull reads + rows changed in that stretch again (a write still in flight may commit a row stamped there) and a domain + that stays quiet never ages its cursor past the tombstone horizon; upserts are idempotent. cursorExpired: type: boolean description: True when the supplied cursor predates tombstone retention, or was issued before the account's data was diff --git a/src/app/api/sync/changes/__tests__/start-now.test.ts b/src/app/api/sync/changes/__tests__/start-now.test.ts index 02f812fb1..1e7612ebb 100644 --- a/src/app/api/sync/changes/__tests__/start-now.test.ts +++ b/src/app/api/sync/changes/__tests__/start-now.test.ts @@ -1,7 +1,7 @@ /** * `GET /api/sync/changes?start=now` (#1339) — a cursor at the present. * - * The answer is an empty page whose cursor sits a minute before `serverNow` + * The answer is an empty page whose cursor sits two minutes before `serverNow` * in every domain (a write stamped before it and committed after it is not * skipped), in the feed's own opaque format, and nothing is read: the point is * to skip the history walk, so a read here would be the bug. The database is @@ -55,7 +55,7 @@ beforeEach(() => { }); describe("GET /api/sync/changes?start=now", () => { - it("answers an empty page with a cursor a minute before serverNow in every domain", async () => { + it("answers an empty page with a cursor two minutes before serverNow in every domain", async () => { const before = Date.now(); const res = await GET(req("?start=now")); const after = Date.now(); @@ -82,7 +82,7 @@ describe("GET /api/sync/changes?start=now", () => { expect(cursor).not.toBeNull(); for (const domain of SYNC_DOMAINS) { expect(cursor![domain], domain).toEqual({ - updatedAtMs: nowMs - 60_000, + updatedAtMs: nowMs - 120_000, id: "0", }); } diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 4d6574219..e9c31b87c 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -30,11 +30,13 @@ * server `id` (the client mirrors those by server id). * - `hasMore` is true when ANY served domain still has rows past its * page; the next `cursor` advances every domain that returned rows. - * When `hasMore` is false the client is caught up as of `serverNow`, - * and that page's cursor is held back to a minute before it in every - * row domain (`holdBackDrainedCursor`): a row stamped in that minute - * whose write commits after the pull still arrives on the next one, at - * the cost of reading that minute twice. + * When `hasMore` is false the client is caught up as of `serverNow`. + * A row domain that ran dry takes a watermark two minutes before + * `serverNow` (`drainedWatermark`; on the last page every one does): + * a row stamped in that stretch whose write commits after the pull + * still arrives on the next one, at the cost of reading the stretch + * twice, and a domain that stays quiet keeps a fresh watermark instead + * of ageing into `cursorExpired`. * - `cursorExpired: true` when ANY domain watermark predates the * tombstone-retention horizon — the client drops its cursor and does * a clean initial sync (a deletion older than retention may have @@ -47,7 +49,7 @@ * cycle tracking off gets both blocks empty (never a 403 — see the * gate note in the handler) and their cursor watermarks untouched. * - * `start=now` (#1339) answers an empty page whose cursor sits a minute + * `start=now` (#1339) answers an empty page whose cursor sits two minutes * before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same * opaque format, so a client that * only wants what changes from now on (the Apple Health write-back) pages diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index bfc9207a7..dc2134410 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -256,7 +256,7 @@ const syncChangesQuery = z .literal("now") .optional() .describe( - "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.", + "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits two minutes before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in those two minutes (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.", ), limit: z.coerce .number() @@ -286,7 +286,7 @@ export const syncChangesResponse = z hasMore: z .boolean() .describe( - "False once the client is caught up as of `serverNow`. The cursor of such a page sits at most a minute before `serverNow` in each row domain, so the next pull reads rows changed in that last minute again (a write still in flight may commit a row stamped there); upserts are idempotent.", + "False once the client is caught up as of `serverNow`. Every row domain that ran dry on a page takes a watermark two minutes before `serverNow` (on the last page of a walk, all of them), so the next pull reads rows changed in that stretch again (a write still in flight may commit a row stamped there) and a domain that stays quiet never ages its cursor past the tombstone horizon; upserts are idempotent.", ), cursorExpired: z .boolean() diff --git a/src/lib/sync/__tests__/cursor.test.ts b/src/lib/sync/__tests__/cursor.test.ts index adaae6cfa..55980b04c 100644 --- a/src/lib/sync/__tests__/cursor.test.ts +++ b/src/lib/sync/__tests__/cursor.test.ts @@ -139,16 +139,16 @@ describe("cursor issue time (sync reset)", () => { }); describe("encodeCursorAtNow", () => { - it("places every domain a minute back, before any id in that millisecond", () => { + it("places every domain two minutes back, before any id in that millisecond", () => { const nowMs = 1_790_000_000_123; const token = encodeCursorAtNow(nowMs); const cursor = decodeCursor(token); // A write stamped before the instant but committed after it is still // ahead of the watermark. - expect(START_NOW_OVERLAP_MS).toBe(60_000); + expect(START_NOW_OVERLAP_MS).toBe(120_000); for (const domain of SYNC_DOMAINS) { expect(cursor?.[domain], domain).toEqual({ - updatedAtMs: nowMs - 60_000, + updatedAtMs: nowMs - 120_000, id: "0", }); } diff --git a/src/lib/sync/changes/pull.ts b/src/lib/sync/changes/pull.ts index 4b5ca8be6..0735a647b 100644 --- a/src/lib/sync/changes/pull.ts +++ b/src/lib/sync/changes/pull.ts @@ -14,7 +14,7 @@ import { decodeCursorIssuedAt, encodeCursor, encodeCursorAtNow, - holdBackDrainedCursor, + drainedWatermark, SYNC_DOMAINS, type SyncCursor, type SyncDomain, @@ -111,7 +111,7 @@ function emptyChanges(): SyncChangesBody["changes"] { } /** - * `start=now` (#1339): an empty page with a cursor a minute before + * `start=now` (#1339): an empty page with a cursor two minutes before * `serverNow` in every domain (`encodeCursorAtNow`), for a client that wants changes from now on (the Apple Health * write-back) without paging through the history first. `hasMore` is false: * the client is caught up as of `serverNow` by definition. @@ -233,7 +233,39 @@ export async function pullSyncChanges(input: { cd.hasMore || cy.hasMore || mm.hasMore; - if (!hasMore) holdBackDrainedCursor(nextCursor, serverNow.getTime()); + + // A row domain whose page ran dry takes the overlap watermark + // (`drainedWatermark`). On the last page of a walk every one does. On an + // earlier page only one whose watermark has not yet passed the overlap + // line: one that walked into the last stretch on a full page keeps its + // place, because reading that stretch again while it holds more than a + // page, with another domain still paging, would never end. The cycle + // domains are left alone while the module is off, and `measurementMirror` + // keeps its token stamps: re-reading its stretch would send every reading + // of a token whose switch just flipped once more on every pull. + const drained = drainedWatermark(serverNow.getTime()); + const rowDomains: Array<[SyncDomain, boolean]> = [ + ["measurements", m.hasMore], + ["mood", md.hasMore], + ["intakes", ik.hasMore], + ...(cycleEnabled + ? ([ + ["cycleDays", cd.hasMore], + ["cycles", cy.hasMore], + ] as Array<[SyncDomain, boolean]>) + : []), + ]; + for (const [domain, domainHasMore] of rowDomains) { + if (domainHasMore) continue; + const before = cursor[domain]; + if ( + !hasMore || + before === undefined || + before.updatedAtMs <= drained.updatedAtMs + ) { + nextCursor[domain] = drained; + } + } annotate({ action: { name: "sync.changes.pull" }, diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index 7cacd33ac..4f6f76e21 100644 --- a/src/lib/sync/cursor.ts +++ b/src/lib/sync/cursor.ts @@ -103,13 +103,16 @@ export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string { } /** - * How far behind the present a `start=now` cursor is placed. `updatedAt` is - * stamped by the application when the write is built, not when it commits, so - * a transaction in flight at `nowMs` can commit a row stamped before it after - * the cursor was handed out; a watermark at `nowMs` would skip that row for - * good. A minute covers any write the app keeps open. + * How far behind the present a caught-up watermark is placed: a `start=now` + * cursor, and every domain a page has drained. `updatedAt` is stamped by the + * application when the write is built, not when it commits, so a transaction + * in flight can commit a row stamped before a watermark after it was handed + * out; a watermark at the present would skip that row for good. The longest + * interactive transaction that writes served rows (the provider syncs and the + * intake import) times out at 60 s, so two minutes covers any of them with + * room to spare. */ -export const START_NOW_OVERLAP_MS = 60_000; +export const START_NOW_OVERLAP_MS = 120_000; /** * A token positioned just before `nowMs` in every domain (#1339), the same @@ -130,31 +133,24 @@ export function encodeCursorAtNow(nowMs: number): string { } /** - * Hold a drained walk's watermarks back to `START_NOW_OVERLAP_MS` before - * `nowMs`, in place. The watermark of a page is the stamp of its last row, - * and that stamp says nothing about rows still in flight: a long write (a - * Garmin or Withings chunk holds its transaction for seconds) stamps its rows - * first and commits them last, so a quick write that commits in between can - * carry the watermark past them, and the keyset would never return them. - * Held back a minute, the next pull reads the last minute again and the - * late rows come with it; anything older is passed as before. Only the - * cursor of a walk that is done is held back: one that still has pages keeps - * moving forward, or a minute holding more than a page could never drain. + * The watermark for a domain whose page has run dry: `START_NOW_OVERLAP_MS` + * before `nowMs`, whichever way that moves it. * - * `measurementMirror` is left alone. Its watermark is a token's mirror - * stamp, written by one short update, and reading its last minute again - * would send every reading of a token whose mirror switch just flipped - * once more on every pull in that minute. + * Back, when the last row delivered is newer: a long write (a provider sync + * holds its transaction for seconds) stamps its rows first and commits them + * last, so a quick write committing in between carried the watermark past + * them and the keyset never returned them. Held back, the next pull reads + * that stretch again and the late rows come with it. + * + * Forward, when the last row is older or there is none: the page just read + * everything past the old watermark, so nothing older than the overlap is + * left behind it. A domain that stays quiet would otherwise keep the stamp + * of its last row for good, and once that is older than the tombstone + * horizon every pull answers `cursorExpired` and the client resyncs from + * scratch, again and again. */ -export function holdBackDrainedCursor(cursor: SyncCursor, nowMs: number): void { - const ceiling = nowMs - START_NOW_OVERLAP_MS; - for (const domain of SYNC_DOMAINS) { - if (domain === "measurementMirror") continue; - const wm = cursor[domain]; - if (wm && wm.updatedAtMs > ceiling) { - cursor[domain] = { updatedAtMs: ceiling, id: "0" }; - } - } +export function drainedWatermark(nowMs: number): DomainWatermark { + return { updatedAtMs: nowMs - START_NOW_OVERLAP_MS, id: "0" }; } function parseWatermark(raw: unknown): DomainWatermark | null { diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap index 6e7ef10b0..33795a4de 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap @@ -3,7 +3,7 @@ "body": { "data": { "serverNow": "", - "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ4MjAwMDAwLCJpIjoibS0wMiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzAwMDAwMCwiaSI6ImluLTAyIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU2NjAwMDAwLCJpIjoiY2QtMDIifSwiY3ljbGVzIjp7InUiOjE3OTIwNTQ4MDAwMDAsImkiOiJjeS0wMiJ9fX0", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ4MjAwMDAwLCJpIjoibS0wMiJ9LCJtb29kIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzAwMDAwMCwiaSI6ImluLTAyIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU2NjAwMDAwLCJpIjoiY2QtMDIifSwiY3ljbGVzIjp7InUiOjE3OTIwNTQ4MDAwMDAsImkiOiJjeS0wMiJ9fX0", "hasMore": true, "cursorExpired": false, "changes": { diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap index 735d06770..2ae2df810 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap @@ -3,7 +3,7 @@ "body": { "data": { "serverNow": "", - "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ5NDAwMDAwLCJpIjoibS0wNCJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU3MjAwMDAwLCJpIjoiY2QtMDMifSwiY3ljbGVzIjp7InUiOjE3OTIwNTU0MDAwMDAsImkiOiJjeS0wMyJ9fX0", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ5NDAwMDAwLCJpIjoibS0wNCJ9LCJtb29kIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImludGFrZXMiOnsidSI6MTc5MjA2NTQ4MDAwMCwiaSI6IjAifSwiY3ljbGVEYXlzIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImN5Y2xlcyI6eyJ1IjoxNzkyMDY1NDgwMDAwLCJpIjoiMCJ9fX0", "hasMore": true, "cursorExpired": false, "changes": { diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap index 97841d250..c8a077f78 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap @@ -3,7 +3,7 @@ "body": { "data": { "serverNow": "", - "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDUwNjAwMDAwLCJpIjoibS0wNiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU3MjAwMDAwLCJpIjoiY2QtMDMifSwiY3ljbGVzIjp7InUiOjE3OTIwNTU0MDAwMDAsImkiOiJjeS0wMyJ9fX0", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDY1NDgwMDAwLCJpIjoiMCJ9LCJtb29kIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImludGFrZXMiOnsidSI6MTc5MjA2NTQ4MDAwMCwiaSI6IjAifSwiY3ljbGVEYXlzIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImN5Y2xlcyI6eyJ1IjoxNzkyMDY1NDgwMDAwLCJpIjoiMCJ9fX0", "hasMore": false, "cursorExpired": false, "changes": { diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap index e74d39368..abfb90e2c 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap @@ -3,7 +3,7 @@ "body": { "data": { "serverNow": "", - "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDUwNjAwMDAwLCJpIjoibS0wNiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU3MjAwMDAwLCJpIjoiY2QtMDMifSwiY3ljbGVzIjp7InUiOjE3OTIwNTU0MDAwMDAsImkiOiJjeS0wMyJ9fX0", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDY1NDgwMDAwLCJpIjoiMCJ9LCJtb29kIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImludGFrZXMiOnsidSI6MTc5MjA2NTQ4MDAwMCwiaSI6IjAifSwiY3ljbGVEYXlzIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImN5Y2xlcyI6eyJ1IjoxNzkyMDY1NDgwMDAwLCJpIjoiMCJ9fX0", "hasMore": false, "cursorExpired": false, "changes": { diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap index 52d0edffe..2f116f524 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap @@ -3,7 +3,7 @@ "body": { "data": { "serverNow": "", - "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ4MjAwMDAwLCJpIjoibS0wMiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzAwMDAwMCwiaSI6ImluLTAyIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU2NjAwMDAwLCJpIjoiY2QtMDIifSwiY3ljbGVzIjp7InUiOjE3OTIwNTQ4MDAwMDAsImkiOiJjeS0wMiJ9fX0", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDQ4MjAwMDAwLCJpIjoibS0wMiJ9LCJtb29kIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzAwMDAwMCwiaSI6ImluLTAyIn0sImN5Y2xlRGF5cyI6eyJ1IjoxNzkyMDU2NjAwMDAwLCJpIjoiY2QtMDIifSwiY3ljbGVzIjp7InUiOjE3OTIwNTQ4MDAwMDAsImkiOiJjeS0wMiJ9fX0", "hasMore": false, "cursorExpired": true, "changes": { diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap index f4c382042..4cf382c69 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap @@ -3,7 +3,7 @@ "body": { "data": { "serverNow": "", - "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDUwNjAwMDAwLCJpIjoibS0wNiJ9LCJtb29kIjp7InUiOjE3OTIwNTE4MDAwMDAsImkiOiJtb29kLTAyIn0sImludGFrZXMiOnsidSI6MTc5MjA1MzYwMDAwMCwiaSI6ImluLTAzIn19fQ", + "cursor": "eyJ2IjoyLCJ0IjoxNzkyMDY1NjAwMDAwLCJkIjp7Im1lYXN1cmVtZW50cyI6eyJ1IjoxNzkyMDY1NDgwMDAwLCJpIjoiMCJ9LCJtb29kIjp7InUiOjE3OTIwNjU0ODAwMDAsImkiOiIwIn0sImludGFrZXMiOnsidSI6MTc5MjA2NTQ4MDAwMCwiaSI6IjAifX19", "hasMore": false, "cursorExpired": false, "changes": { diff --git a/tests/integration/sync-changes-start-now.test.ts b/tests/integration/sync-changes-start-now.test.ts index 72a63bb5d..9a424949e 100644 --- a/tests/integration/sync-changes-start-now.test.ts +++ b/tests/integration/sync-changes-start-now.test.ts @@ -8,7 +8,7 @@ * that token's readings on `measurementMirror`, because the switch happens * after the cursor. * - * The cursor sits a minute behind the present: `updatedAt` is stamped when a + * The cursor sits two minutes behind the present: `updatedAt` is stamped when a * write is built, not when it commits, so a row stamped just before the * cursor can become visible just after it, and must not be skipped. */ @@ -138,7 +138,7 @@ describe("a cursor that starts now", () => { ); expect(mirrored.every((r) => r.appleHealthMirror === true)).toBe(true); - // The drained cursor it hands back sits a minute back like the start + // The drained cursor it hands back sits two minutes back like the start // one, so the row written in that minute comes once more and nothing // else does. The mirror domain is not held back. const after = await pull({ cursor: next.cursor! }); diff --git a/tests/integration/sync-changes.test.ts b/tests/integration/sync-changes.test.ts index b7092d344..f5f8b1f2f 100644 --- a/tests/integration/sync-changes.test.ts +++ b/tests/integration/sync-changes.test.ts @@ -585,6 +585,86 @@ describe("GET /api/sync/changes — multi-domain (real Postgres)", () => { expect(j.data.hasMore).toBe(true); }); + it("keeps catching up when a domain has had no change since before the retention horizon", async () => { + // Someone who stopped logging mood months ago: the newest mood row is + // older than the horizon. A drained walk proved nothing changed since, + // so its cursor must not read as expired on the next pull. + const prisma = getPrismaClient(); + await prisma.moodEntry.create({ + data: { + userId: TEST_USER_ID, + date: "2026-01-02", + mood: "GUT", + score: 4, + moodLoggedAt: new Date( + Date.now() - (TOMBSTONE_RETENTION_DAYS + 10) * 86_400_000, + ), + updatedAt: new Date( + Date.now() - (TOMBSTONE_RETENTION_DAYS + 10) * 86_400_000, + ), + }, + }); + await seedLive(1); + const { GET } = await import("@/app/api/sync/changes/route"); + const first = (await (await GET(makeRequest())).json()) as { + data: ChangesData; + }; + expect(first.data.hasMore).toBe(false); + expect(first.data.changes.mood.upserts).toHaveLength(1); + const second = (await ( + await GET( + makeRequest(`?cursor=${encodeURIComponent(first.data.cursor!)}`), + ) + ).json()) as { data: ChangesData }; + expect(second.data.cursorExpired).toBe(false); + }); + + it("delivers a late row in a domain that ran dry on an earlier page of the walk", async () => { + // Two measurements keep the walk going at limit=1; the mood domain runs + // dry on the first page. A mood row stamped before that page's mood row + // and committed after it still arrives before the walk ends. + await seedLive(2); + const prisma = getPrismaClient(); + const quick = await prisma.moodEntry.create({ + data: { + userId: TEST_USER_ID, + date: fixtureDay(1), + mood: "GUT", + score: 4, + moodLoggedAt: new Date(), + }, + }); + const { GET } = await import("@/app/api/sync/changes/route"); + const page1 = (await (await GET(makeRequest("?limit=1"))).json()) as { + data: ChangesData; + }; + expect(page1.data.hasMore).toBe(true); + expect(page1.data.changes.mood.upserts.map((r) => r.id)).toEqual([ + quick.id, + ]); + const late = await prisma.moodEntry.create({ + data: { + userId: TEST_USER_ID, + date: fixtureDay(2), + mood: "GUT", + score: 3, + moodLoggedAt: new Date(), + updatedAt: new Date(quick.updatedAt.getTime() - 1_000), + }, + }); + const seen = new Set(); + let cursor = page1.data.cursor!; + for (let i = 0; i < 5; i++) { + const page = (await ( + await GET(makeRequest(`?limit=1&cursor=${encodeURIComponent(cursor)}`)) + ).json()) as { data: ChangesData }; + for (const row of page.data.changes.mood.upserts) seen.add(row.id); + cursor = page.data.cursor!; + if (!page.data.hasMore) break; + } + expect(seen).toContain(late.id); + }); + it("treats a garbage cursor as a clean initial sync", async () => { await seedMood(1); const { GET } = await import("@/app/api/sync/changes/route"); From c14cc9ab470a21f1a87489019b9db74b46b702c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:53:47 +0100 Subject: [PATCH 10/46] fix(cycle): turning the feed off wins against a rotation in either order DELETE revoked the one feed it had read, so a rotation committing while it waited left the successor live behind a 200. It now revokes every live feed of the record in two passes. A rotation that loses to a turn-off answers 404 cycle.feed.none instead of 409; PATCH documents its 409. The in-repo feed guide describes the three forecast styles. --- docs/using/cycle-calendar-feed.md | 19 +++++--- .../cycle/feed-subscription/rotate/route.ts | 15 ++++-- src/app/api/cycle/feed-subscription/route.ts | 15 ++++-- src/lib/openapi/routes/cycle-feed.ts | 9 +++- tests/integration/cycle-calendar-feed.test.ts | 47 ++++++++++++++++--- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/using/cycle-calendar-feed.md b/docs/using/cycle-calendar-feed.md index b05c53d66..3ddb6b29a 100644 --- a/docs/using/cycle-calendar-feed.md +++ b/docs/using/cycle-calendar-feed.md @@ -32,16 +32,23 @@ managed on the web; the iOS app shows where to find it. ## What the feed shows - The next period and, once three cycles are logged, a few after it (three - by default, up to six). Each event covers the whole window the period may - fall in, and the window widens the further out it is. The list stops itself - where a date would be more guess than estimate (beyond about ±10 days, or - half a cycle). + by default, up to six). The range a period may fall in widens the further + out it is, and the list stops itself where a date would be more guess + than estimate (beyond about ±10 days, or half a cycle). How that range is + drawn is up to you, under "How forecasts look": + - **Expected days with margin** (the default for a new subscription): the + expected days as one event, and the days the period may start earlier + or run on later as separate "Period possible" events. + - **Expected days**: only the expected days, as one event. + - **One range**: one event covering the whole range. A subscription + turned on before v1.47.2 keeps this style until you change it, and + renewing the link keeps whichever style it has. - Optionally, your past cycles (up to twelve). - Optionally, when your goal is trying to conceive, the estimated fertile window of the next cycle. It is an estimate, not a contraceptive method. -Titles are discreet by default: each event is a dot and the calendar is called -"HealthLog". Descriptive titles name the period instead. Events never carry +Titles are discreet by default: a period is a dot (●), a possible day an open +dot (○), and the calendar is called "HealthLog". Descriptive titles name the period instead. Events never carry symptoms, notes or any other detail of your log. When a period is later than its window, the feed shows no forecast until you diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 70916a245..755f171ea 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -10,7 +10,8 @@ * URL is returned exactly once. Of two rotations racing, the second finds * the feed already revoked (or its successor meets the one-live-feed index) * and gets the mint path's 409 `cycle.feed.exists`; the winner's link - * stands. A subscription turned off during a rotation stays off, and an + * stands. A subscription turned off during a rotation stays off, whichever + * commits first, and the rotation then answers 404 `cycle.feed.none`; an * option changed during one lands on the link that stays live. * * Same gates as turning the subscription on: cookie session only, the owner or @@ -36,7 +37,11 @@ import { feedLinks, noLiveFeed, } from "@/lib/cycle/feed/manage"; -import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status"; +import { + FEED_SUMMARY_SELECT, + findLiveFeed, + toFeedSummary, +} from "@/lib/cycle/feed/status"; import { mintFeedToken } from "@/lib/cycle/feed/token"; import { prisma } from "@/lib/db"; import { resolveOrigin } from "@/lib/http/origin"; @@ -128,7 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => { if (isP2002(err)) return "raced" as const; throw err; }); - if (rotated === "raced") return feedExists(); + // Lost to another rotation, the link that won stands; lost to the + // subscription being turned off, there is none. + if (rotated === "raced") { + return (await findLiveFeed(user.id)) ? feedExists() : noLiveFeed(); + } if (!rotated) return noLiveFeed(); return apiSuccess( diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 30dc3e1e3..a91f2d627 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -245,10 +245,17 @@ export const DELETE = apiHandler(async () => { const live = await findLiveFeed(user.id); if (!live) return noLiveFeed(); - await prisma.cycleCalendarFeed.update({ - where: { id: live.id }, - data: { revokedAt: new Date() }, - }); + // Every live feed of the record, not only the one read above, and twice: + // a rotation that held that row while this waited commits its successor + // after the first statement looked, and the second finds it. Off stays + // off even when the link was being renewed at the same moment. + const revokedAt = new Date(); + for (let pass = 0; pass < 2; pass++) { + await prisma.cycleCalendarFeed.updateMany({ + where: { userId: user.id, revokedAt: null }, + data: { revokedAt }, + }); + } // The link stops working on the provider's next poll. What the provider // already copied is beyond reach, which the settings card says before the // link is ever made. diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index 504bb005e..4648b1c1a 100644 --- a/src/lib/openapi/routes/cycle-feed.ts +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -219,7 +219,7 @@ export const cycleFeedPaths: NonNullable = { tags: ["Cycle"], summary: "Change the calendar subscription's options (#1350)", description: - "Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.", + "Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. A change saved while the link is being replaced applies to the link that stays live.", requestBody: { required: true, content: { "application/json": { schema: feedOptionsRequest } }, @@ -237,6 +237,11 @@ export const cycleFeedPaths: NonNullable = { description: "No subscription is on (`cycle.feed.none`).", content: { "application/json": { schema: errorEnvelope } }, }, + "409": { + description: + "The link was replaced twice while the change was being saved (`cycle.feed.exists`); nothing changed, and sending it again applies it to the link that is live now.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...MANAGEMENT_REFUSALS, ...malformedJsonResponse, ...stdResponses, @@ -275,7 +280,7 @@ export const cycleFeedPaths: NonNullable = { tags: ["Cycle"], summary: "Replace the calendar subscription's link (#1350)", description: - "One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.", + "One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands; a rotation racing the subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off.", responses: { ...mintResponses, "404": { diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 47c775d9d..130569510 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -243,14 +243,15 @@ describe("turning the subscription on", () => { }); /** - * Run `rotate()` while `write` holds the live feed's row in an open - * transaction, and commit `write` only once the rotation is waiting on - * that row: the rotation has read the feed, and its revoke comes after - * the competing write whatever the scheduler does. + * Run `act` (a rotation unless named) while `write` holds the live feed's + * row in an open transaction, and commit `write` only once `act` is + * waiting on that row: `act` has read the feed, and its own write comes + * after the competing one whatever the scheduler does. */ async function rotateAround( userId: string, write: (tx: Prisma.TransactionClient, feedId: string) => Promise, + act: () => Promise = rotate, ): Promise { const feed = await db().cycleCalendarFeed.findFirstOrThrow({ where: { userId, revokedAt: null }, @@ -260,7 +261,7 @@ describe("turning the subscription on", () => { await db().$transaction( async (tx) => { await write(tx, feed.id); - pending = rotate(); + pending = act(); for (let i = 0; i < 100; i++) { const [{ n }] = await db().$queryRaw<{ n: number }[]>` SELECT count(*)::int AS n FROM pg_stat_activity @@ -268,7 +269,7 @@ describe("turning the subscription on", () => { if (n > 0) return; await new Promise((r) => setTimeout(r, 20)); } - throw new Error("the rotation never waited on the feed row"); + throw new Error("the request never waited on the feed row"); }, { timeout: 10_000 }, ); @@ -284,7 +285,39 @@ describe("turning the subscription on", () => { data: { revokedAt: new Date() }, }); }); - expect(res.status).toBe(409); + expect(res.status).toBe(404); + expect((await res.json()).meta.errorCode).toBe("cycle.feed.none"); + expect( + await db().cycleCalendarFeed.count({ + where: { userId: owner.id, revokedAt: null }, + }), + ).toBe(0); + }); + + it("keeps a subscription off when it is turned off while a rotation commits", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + // The rotation holds the live row (revoked, successor written, not yet + // committed) while the turn-off waits on it. + const res = await rotateAround( + owner.id, + async (tx, feedId) => { + await tx.cycleCalendarFeed.update({ + where: { id: feedId }, + data: { revokedAt: new Date() }, + }); + await tx.cycleCalendarFeed.create({ + data: { + userId: owner.id, + createdById: owner.id, + tokenHash: "successor-hash", + tokenPrefix: "hlf_successor", + }, + }); + }, + () => manage("DELETE"), + ); + expect(res.status).toBe(200); expect( await db().cycleCalendarFeed.count({ where: { userId: owner.id, revokedAt: null }, From 69ba8bec914eb5670a3fadf008441f37ffa13eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:53:47 +0100 Subject: [PATCH 11/46] fix(measurements): read the GKI's glucose from the ketone side The correlated EXISTS over a time range had no equality to drive it from the ketones, so a person with years of sensor glucose walked every glucose row in the window. Each ketone reading now reads its glucose through a LATERAL index range. A full tie between two glucose readings resolves by id, the day tile names the unit, and the empty text says a ketone reading above zero. --- docs/api/openapi.yaml | 13 +++++-- messages/de.json | 4 +-- messages/en.json | 4 +-- messages/es.json | 4 +-- messages/fr.json | 4 +-- messages/it.json | 4 +-- messages/ko.json | 4 +-- messages/nl.json | 4 +-- messages/pl.json | 4 +-- messages/pt-BR.json | 4 +-- messages/ru.json | 4 +-- .../dense-measurement-read-guard.test.ts | 2 -- src/components/day/day-gki-tile.tsx | 2 ++ .../__tests__/glucose-ketone-index.test.ts | 12 +++++++ src/lib/measurements/glucose-ketone-index.ts | 6 +++- .../measurements/read-glucose-ketone-index.ts | 36 +++++++++---------- 16 files changed, 67 insertions(+), 44 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f00512ce5..148febd8d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -904,7 +904,8 @@ paths: tags: - Cycle summary: Change the calendar subscription's options (#1350) - description: Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. + description: Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new + content. A change saved while the link is being replaced applies to the link that stays live. requestBody: required: true content: @@ -927,6 +928,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The link was replaced twice while the change was being saved (`cycle.feed.exists`); nothing changed, and + sending it again applies it to the link that is live now. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a12 delete: @@ -959,7 +967,8 @@ paths: summary: Replace the calendar subscription's link (#1350) description: "One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, - the one that loses answers 409 `cycle.feed.exists` and the winner's link stands." + the one that loses answers 409 `cycle.feed.exists` and the winner's link stands; a rotation racing the + subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off." responses: "201": *a13 "401": *a14 diff --git a/messages/de.json b/messages/de.json index c9df357c7..e7bc53513 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.", "emptyTitle": "Noch kein Index", - "emptyDescription": "Er erscheint, sobald zu einem Ketonwert ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.", + "emptyDescription": "Er erscheint, sobald zu einem Ketonwert über null ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.", "latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Alle {count} Werte", "valuesAllOther": "Alle {count} Werte", "valuesFewer": "Weniger Werte", - "gkiPair": "Blutzucker {glucose} / Ketone {ketones}", + "gkiPair": "Blutzucker {glucose} {unit} / Ketone {ketones} {unit}", "gkiCountOne": "Letzter von {count}", "gkiCountFew": "Letzter von {count}", "gkiCountOther": "Letzter von {count}", diff --git a/messages/en.json b/messages/en.json index 48ed32245..f2d3a970d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.", "emptyTitle": "No index yet", - "emptyDescription": "It appears once a ketone reading has a glucose reading taken at the same time or within 15 minutes.", + "emptyDescription": "It appears once a ketone reading above zero has a glucose reading taken at the same time or within 15 minutes.", "latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "All {count} values", "valuesAllOther": "All {count} values", "valuesFewer": "Fewer values", - "gkiPair": "Glucose {glucose} / ketones {ketones}", + "gkiPair": "Glucose {glucose} {unit} / ketones {ketones} {unit}", "gkiCountOne": "Latest of {count}", "gkiCountFew": "Latest of {count}", "gkiCountOther": "Latest of {count}", diff --git a/messages/es.json b/messages/es.json index 6ea863120..aea9a878c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.", "emptyTitle": "Aún no hay índice", - "emptyDescription": "Aparece cuando una lectura de cetonas tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.", + "emptyDescription": "Aparece cuando una lectura de cetonas mayor que cero tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.", "latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Los {count} valores", "valuesAllOther": "Los {count} valores", "valuesFewer": "Menos valores", - "gkiPair": "Glucosa {glucose} / cetonas {ketones}", + "gkiPair": "Glucosa {glucose} {unit} / cetonas {ketones} {unit}", "gkiCountOne": "Último de {count}", "gkiCountFew": "Último de {count}", "gkiCountOther": "Último de {count}", diff --git a/messages/fr.json b/messages/fr.json index 0f19fac15..8ee397390 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.", "emptyTitle": "Pas encore d’indice", - "emptyDescription": "Il apparaît dès qu’un relevé de cétones a une glycémie prise au même moment ou à 15 minutes près.", + "emptyDescription": "Il apparaît dès qu’un relevé de cétones supérieur à zéro a une glycémie prise au même moment ou à 15 minutes près.", "latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Les {count} valeurs", "valuesAllOther": "Les {count} valeurs", "valuesFewer": "Moins de valeurs", - "gkiPair": "Glycémie {glucose} / cétones {ketones}", + "gkiPair": "Glycémie {glucose} {unit} / cétones {ketones} {unit}", "gkiCountOne": "Dernier sur {count}", "gkiCountFew": "Dernier sur {count}", "gkiCountOther": "Dernier sur {count}", diff --git a/messages/it.json b/messages/it.json index 17b61f5a1..13db50090 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.", "emptyTitle": "Ancora nessun indice", - "emptyDescription": "Compare quando una lettura di chetoni ha una glicemia presa allo stesso momento o entro 15 minuti.", + "emptyDescription": "Compare quando una lettura di chetoni superiore a zero ha una glicemia presa allo stesso momento o entro 15 minuti.", "latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Tutti i {count} valori", "valuesAllOther": "Tutti i {count} valori", "valuesFewer": "Meno valori", - "gkiPair": "Glicemia {glucose} / chetoni {ketones}", + "gkiPair": "Glicemia {glucose} {unit} / chetoni {ketones} {unit}", "gkiCountOne": "Ultimo di {count}", "gkiCountFew": "Ultimo di {count}", "gkiCountOther": "Ultimo di {count}", diff --git a/messages/ko.json b/messages/ko.json index 213235474..0e4464586 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4019,7 +4019,7 @@ "gki": { "description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.", "emptyTitle": "아직 지수가 없어요", - "emptyDescription": "케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.", + "emptyDescription": "0보다 큰 케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.", "latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "수치 {count}개 모두", "valuesAllOther": "수치 {count}개 모두", "valuesFewer": "수치 줄여 보기", - "gkiPair": "혈당 {glucose} / 케톤 {ketones}", + "gkiPair": "혈당 {glucose} {unit} / 케톤 {ketones} {unit}", "gkiCountOne": "{count}개 중 최근", "gkiCountFew": "{count}개 중 최근", "gkiCountOther": "{count}개 중 최근", diff --git a/messages/nl.json b/messages/nl.json index 57be9591e..c78d5a4ab 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.", "emptyTitle": "Nog geen index", - "emptyDescription": "Hij verschijnt zodra een ketonmeting een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.", + "emptyDescription": "Hij verschijnt zodra een ketonmeting boven nul een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.", "latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Alle {count} waarden", "valuesAllOther": "Alle {count} waarden", "valuesFewer": "Minder waarden", - "gkiPair": "Glucose {glucose} / ketonen {ketones}", + "gkiPair": "Glucose {glucose} {unit} / ketonen {ketones} {unit}", "gkiCountOne": "Laatste van {count}", "gkiCountFew": "Laatste van {count}", "gkiCountOther": "Laatste van {count}", diff --git a/messages/pl.json b/messages/pl.json index 5f4636245..a411d2f18 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.", "emptyTitle": "Brak jeszcze indeksu", - "emptyDescription": "Pojawi się, gdy do odczytu ketonów będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.", + "emptyDescription": "Pojawi się, gdy do odczytu ketonów powyżej zera będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.", "latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Wszystkie {count} wartości", "valuesAllOther": "Wszystkie {count} wartości", "valuesFewer": "Mniej wartości", - "gkiPair": "Glukoza {glucose} / ketony {ketones}", + "gkiPair": "Glukoza {glucose} {unit} / ketony {ketones} {unit}", "gkiCountOne": "Ostatni z {count}", "gkiCountFew": "Ostatni z {count}", "gkiCountOther": "Ostatni z {count}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 3d09866b4..754f44ed9 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.", "emptyTitle": "Ainda não há índice", - "emptyDescription": "Ele aparece quando uma leitura de cetonas tem uma de glicose feita no mesmo horário ou em até 15 minutos.", + "emptyDescription": "Ele aparece quando uma leitura de cetonas acima de zero tem uma de glicose feita no mesmo horário ou em até 15 minutos.", "latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Todos os {count} valores", "valuesAllOther": "Todos os {count} valores", "valuesFewer": "Menos valores", - "gkiPair": "Glicose {glucose} / cetonas {ketones}", + "gkiPair": "Glicose {glucose} {unit} / cetonas {ketones} {unit}", "gkiCountOne": "Último de {count}", "gkiCountFew": "Último de {count}", "gkiCountOther": "Último de {count}", diff --git a/messages/ru.json b/messages/ru.json index 9a206fa9f..f3c9d7077 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -4019,7 +4019,7 @@ "gki": { "description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.", "emptyTitle": "Индекса пока нет", - "emptyDescription": "Он появится, когда к измерению кетонов будет измерение глюкозы в то же время или в пределах 15 минут.", + "emptyDescription": "Он появится, когда к измерению кетонов выше нуля будет измерение глюкозы в то же время или в пределах 15 минут.", "latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}." } }, @@ -13710,7 +13710,7 @@ "valuesAllFew": "Все {count} значения", "valuesAllOther": "Все {count} значений", "valuesFewer": "Меньше значений", - "gkiPair": "Глюкоза {glucose} / кетоны {ketones}", + "gkiPair": "Глюкоза {glucose} {unit} / кетоны {ketones} {unit}", "gkiCountOne": "Последний из {count}", "gkiCountFew": "Последний из {count}", "gkiCountOther": "Последний из {count}", diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 34880beb6..1ddba1dce 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -151,8 +151,6 @@ const ALLOWED_SQL: Record = { "Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.", "lib/measurements/read-glucose-ketone-index.ts::1": "Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.", - "lib/measurements/read-glucose-ketone-index.ts::2": - "Glucose rows only within 15 minutes of one of those ketone readings (`EXISTS`): a handful per fingerstick even under a sensor, never the window's glucose series.", "lib/dashboard/summary-reads.ts::2": "Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.", }; diff --git a/src/components/day/day-gki-tile.tsx b/src/components/day/day-gki-tile.tsx index d078a2e8a..1dd807099 100644 --- a/src/components/day/day-gki-tile.tsx +++ b/src/components/day/day-gki-tile.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import type { DayResponse } from "@/lib/day/contract"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index"; type DayGki = NonNullable[number]; @@ -38,6 +39,7 @@ export function DayGkiTile({ points }: { points: readonly DayGki[] }) { : t("day.gkiPair", { glucose: fmt.number(latest.glucoseMmol, 1), ketones: fmt.number(latest.ketonesMmol, 1), + unit: GKI_COMPONENT_UNIT, })} diff --git a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts index 2c91dee3c..27ead05d2 100644 --- a/src/lib/measurements/__tests__/glucose-ketone-index.test.ts +++ b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts @@ -115,6 +115,18 @@ describe("pairGlucoseKetoneIndex", () => { expect(byTime?.glucoseId).toBe("g-earlier"); }); + it("gives the same pair for a full tie whatever order the rows came in", () => { + const a = reading("g-a", 0, 90, "APPLE_HEALTH"); + const b = reading("g-b", 0, 110, "NIGHTSCOUT"); + for (const glucose of [ + [a, b], + [b, a], + ]) { + const [point] = pairGlucoseKetoneIndex([reading("k1", 0, 1)], glucose); + expect(point?.glucoseId).toBe("g-a"); + } + }); + it("answers in ketone order whatever order the readings came in", () => { const points = pairGlucoseKetoneIndex( [reading("k2", 60, 2), reading("k1", 0, 1)], diff --git a/src/lib/measurements/glucose-ketone-index.ts b/src/lib/measurements/glucose-ketone-index.ts index 46bf54dfa..50e130994 100644 --- a/src/lib/measurements/glucose-ketone-index.ts +++ b/src/lib/measurements/glucose-ketone-index.ts @@ -106,8 +106,12 @@ export function pairGlucoseKetoneIndex( ketones: readonly GkiReading[], glucose: readonly GkiReading[], ): GlucoseKetoneIndexPoint[] { + // The id breaks the last tie, so two glucose readings alike in time and + // source always give the same pair, whatever order the rows came in. const sortedGlucose = [...glucose].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + (a, b) => + a.measuredAt.getTime() - b.measuredAt.getTime() || + a.id.localeCompare(b.id), ); const sortedKetones = [...ketones].sort( (a, b) => diff --git a/src/lib/measurements/read-glucose-ketone-index.ts b/src/lib/measurements/read-glucose-ketone-index.ts index cc3afe988..bb17fb7a6 100644 --- a/src/lib/measurements/read-glucose-ketone-index.ts +++ b/src/lib/measurements/read-glucose-ketone-index.ts @@ -15,7 +15,6 @@ import { prisma } from "@/lib/db"; import { GKI_PAIRING_WINDOW_MINUTES, - GKI_PAIRING_WINDOW_MS, pairGlucoseKetoneIndex, type GkiReading, type GlucoseKetoneIndexPoint, @@ -62,25 +61,24 @@ export async function readGlucoseKetoneIndex(args: { if (ketones.length === 0) return []; // An interval between two stored instants: no zone enters the arithmetic. const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`; + // Driven from the ketone side: one index range per ketone reading on + // (user_id, type, measured_at), so a person with years of CGM glucose + // reads only the glucose around their fingersticks, never the series. A + // correlated EXISTS over a range had no equality to drive it the other + // way and walked every glucose row in the window. + const at = ketones.map((k) => k.measured_at); const glucose = await prisma.$queryRaw` - SELECT g."id", g."value", g."measured_at", g."source"::text AS "source" - FROM measurements g - WHERE g."user_id" = ${userId} - AND g."type" = 'BLOOD_GLUCOSE'::"measurement_type" - AND g."deleted_at" IS NULL - AND g."measured_at" >= ${new Date(from.getTime() - GKI_PAIRING_WINDOW_MS)} - AND g."measured_at" <= ${new Date(to.getTime() + GKI_PAIRING_WINDOW_MS)} - AND EXISTS ( - SELECT 1 FROM measurements k - WHERE k."user_id" = ${userId} - AND k."type" = 'BLOOD_KETONES'::"measurement_type" - AND k."deleted_at" IS NULL - AND k."measured_at" >= ${from} - AND k."measured_at" < ${to} - AND k."value" > 0 - AND k."measured_at" BETWEEN g."measured_at" - ${reach}::interval - AND g."measured_at" + ${reach}::interval - ) + SELECT DISTINCT g."id", g."value", g."measured_at", g."source" + FROM unnest(${at}::timestamp(3)[]) AS k("at") + CROSS JOIN LATERAL ( + SELECT m."id", m."value", m."measured_at", m."source"::text AS "source" + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type" + AND m."deleted_at" IS NULL + AND m."measured_at" BETWEEN k."at" - ${reach}::interval + AND k."at" + ${reach}::interval + ) g `; return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading)); } From 4dcb041612c6d3d0d3d63b53a5f6356b324a360e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:53:47 +0100 Subject: [PATCH 12/46] fix(labs): review a unit that was only a calc note, keep unseen category places A document unit that held only "(calc)" read as no unit and went straight to the record; it now goes to review. Saving a category order keeps the saved keys not on screen after the shown ones. The changelog says what each fix does and no more. --- CHANGELOG.md | 30 +++++++++++-------- src/components/labs/labs-settings.tsx | 7 ++++- src/lib/documents/__tests__/extract.test.ts | 3 ++ src/lib/documents/extract.ts | 8 ++++- .../__tests__/module-list-layout.test.ts | 13 ++++++++ src/lib/settings/module-list-layout.ts | 18 +++++++++++ 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba2145eb..270544dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,18 +31,20 @@ (built-in categories in a fixed order, your own panels alphabetically, Other last). A category that turns up later joins the end of your order, and Other stays last if you left it there. The order is saved - with your account and applied by the server, so the app shows the same - order. The labs list layout accepts `categoryOrder`, and + with your account and applied by the server, so every device that + groups the list shows the same order. The labs list layout accepts + `categoryOrder`, and `GET /api/labs/overview` answers `categories` in that order. (#1349) - **Blood ketones, and the glucose-ketone index worked out for you.** Ketones (beta-hydroxybutyrate, mmol/L) are a measurement of their own now, so a ketogenic diet no longer has to live in a custom metric with the index typed in by hand beside it. Choose "Blood ketones" when you - add a measurement and type the glucose from the same fingerstick into - the field below: both readings are saved together with one time, or - neither is. The glucose-ketone index is glucose in mmol/L divided by - ketones, taken from the glucose reading at the same time or, failing - that, the nearest one within 15 minutes. It is never stored, so it + add a measurement and, if the meter gave one, type the glucose from the + same fingerstick into the field below: both readings are saved together + with one time, or neither is. The glucose-ketone index is glucose in + mmol/L divided by ketones, for every ketone reading above zero, taken + from the glucose reading at the same time or, failing that, the nearest + one within 15 minutes. It is never stored, so it follows any reading you edit or delete. It shows under the chart on the new Ketones page, in the day view, and beside the ketones in the doctor report and the clinician share; the FHIR export carries the ketone @@ -58,10 +60,12 @@ could copy it into the unit, so a marker was created in "g/dL (calc)" or just "(calc)" and the next report's plain "g/dL" was refused as a different unit. The read now drops that note (also "calculated", - "computed" and the German "berechnet"), a unit that was only the note - counts as no unit, and a marker already saved with the note accepts - the plain unit. Any other text in brackets still counts as part of - the unit. (#1313) + "computed" and the German "berechnet"), a marker already saved in + "g/dL (calc)" accepts the plain "g/dL", and a unit that was only the + note counts as no unit and goes to review, so a person decides whether + the value really has none. A marker saved in just "(calc)" keeps that + unit until you change it on the marker. Any other text in brackets + still counts as part of the unit. (#1313) - **The sync feed no longer loses a reading written during a long import, and no longer starts over for a quiet account.** A Garmin or Withings import writes a batch in one transaction and stamps its @@ -80,8 +84,8 @@ - **Changing the calendar subscription while its link is being renewed.** A subscription turned off at the same moment its link was renewed could come back under the new link, and an option changed at that moment - could be lost to the new link. Turning it off now wins, and the change - lands on the link that stays live. + could be lost to the new link. Turning it off now wins whichever comes + first, and the change lands on the link that stays live. ## [1.47.1] — 2026-11-07 diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index a5723137f..371760653 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -49,6 +49,7 @@ import { type ModuleGroupsOpen, type ModuleSortDir, } from "@/lib/module-list-prefs"; +import { mergeCategoryOrder } from "@/lib/settings/module-list-layout"; import { BiomarkerManager } from "./biomarker-manager"; import type { @@ -134,7 +135,11 @@ export function LabsSettings() { /> {prefs.groupBy === "category" ? ( - + + setCategoryOrder(mergeCategoryOrder(shown, prefs.categoryOrder)) + } + /> ) : null} {manual ? ( diff --git a/src/lib/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts index af441a5ba..e18ee8f8c 100644 --- a/src/lib/documents/__tests__/extract.test.ts +++ b/src/lib/documents/__tests__/extract.test.ts @@ -221,6 +221,9 @@ describe("runInboundExtraction — stated-status-only mapping", () => { expect( result.facts.map((f) => (f.data as ObservationFactData).unit), ).toEqual(["g/dL", null]); + // A unit that was only the note goes to review: no unit is a choice a + // person makes, not one a read makes for them. + expect(result.facts.map((f) => f.needsReview)).toEqual([false, true]); }); it("stores the document's real span, not the model's echo of it", async () => { diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index d1e6bc8bc..54000da5f 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -399,9 +399,15 @@ export async function runInboundExtraction( // A unit of "1" is the user's explicit no-unit choice. A read that // states it itself goes to review rather than straight to the record, // so a dropped unit never passes as a dimensionless value. + // So does a unit column that held only a "(calc)" note: it reads as no + // unit, and a person decides whether the value really has none. needsReview: confidence < INBOUND_CONFIDENCE_FLOOR || - (raw.type === "OBSERVATION" && isDimensionlessUnit(raw.unit)), + (raw.type === "OBSERVATION" && + (isDimensionlessUnit(raw.unit) || + (raw.unit !== null && + raw.unit.trim() !== "" && + observationUnit(raw.unit) === null))), data, provenance: { sourceText: anchor.sourceText, diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts index b5081cdef..08affe190 100644 --- a/src/lib/settings/__tests__/module-list-layout.test.ts +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -6,6 +6,7 @@ import { normaliseModuleListOrder, resolveModuleListLayout, storedModuleListLayout, + mergeCategoryOrder, } from "../module-list-layout"; describe("resolveModuleListLayout", () => { @@ -143,3 +144,15 @@ describe("storedModuleListLayout", () => { ).toEqual({ labs: { sortDir: "manual" } }); }); }); + +describe("mergeCategoryOrder", () => { + it("keeps the place of a saved category that is not on screen", () => { + expect( + mergeCategoryOrder(["lipids", "liver"], ["liver", "thyroid", "lipids"]), + ).toEqual(["lipids", "liver", "thyroid"]); + }); + + it("is the shown order when nothing else was saved", () => { + expect(mergeCategoryOrder(["b", "a"], [])).toEqual(["b", "a"]); + }); +}); diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts index f57bc607d..8a0d3c0a7 100644 --- a/src/lib/settings/module-list-layout.ts +++ b/src/lib/settings/module-list-layout.ts @@ -62,6 +62,24 @@ export const MODULE_LIST_ID_MAX = 64; */ export const MODULE_LIST_CATEGORY_ORDER_MAX = 200; +/** + * The category order to save after a move: the categories on screen in + * their new order, then every saved key that is not on screen now, in its + * saved order. A category without readings at the moment (or one of another + * record a guardian arranged) keeps its place in the list instead of being + * dropped by a move that never showed it. + */ +export function mergeCategoryOrder( + shown: readonly string[], + saved: readonly string[], +): string[] { + const onScreen = new Set(shown); + return [...shown, ...saved.filter((key) => !onScreen.has(key))].slice( + 0, + MODULE_LIST_CATEGORY_ORDER_MAX, + ); +} + export interface ModuleListPrefs { view: ModuleListView; order: string[]; From d110f0456ca73f139b9dab88989962b81d5d3cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 20:07:51 +0100 Subject: [PATCH 13/46] chore(bundle): baseline the ketones page --- bundle-budget.json | 1 + 1 file changed, 1 insertion(+) diff --git a/bundle-budget.json b/bundle-budget.json index 0b6a0f74c..393864f80 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -58,6 +58,7 @@ "/insights/headphone-audio/page": 336, "/insights/health-score/page": 315, "/insights/hrv/page": 337, + "/insights/ketones/page": 339, "/insights/lean-body-mass/page": 336, "/insights/medications/page": 338, "/insights/mood/page": 337, From b4ac67ee7caedd794472622f368f26b5ee7f9a33 Mon Sep 17 00:00:00 2001 From: Konrad Garbal Date: Tue, 10 Nov 2026 19:25:56 +0100 Subject: [PATCH 14/46] feat(labs): add biomarker filtering and sorting --- .gitignore | 4 + CHANGELOG.md | 10 + docs/api/openapi.yaml | 10 +- docs/ui-guidelines.md | 15 +- messages/de.json | 15 + messages/en.json | 15 + messages/es.json | 15 + messages/fr.json | 15 + messages/it.json | 15 + messages/ko.json | 15 + messages/nl.json | 15 + messages/pl.json | 15 + messages/pt-BR.json | 15 + messages/ru.json | 15 + ...s-overview-response-consumer-guard.test.ts | 41 +- .../lab-filter-rail.browser.test.tsx | 158 +++++ .../labs/__tests__/lab-filter-rail.test.tsx | 195 ++++++ .../labs/__tests__/lab-list-columns.test.tsx | 5 +- .../labs/__tests__/lab-list-filters.test.ts | 356 +++++++++++ .../lab-list-url-sync.browser.test.tsx | 562 ++++++++++++++++++ .../labs/__tests__/lab-list-url-sync.test.tsx | 249 ++++++++ .../labs/__tests__/lab-list.test.tsx | 384 ++++++++++++ src/components/labs/lab-filter-rail.tsx | 205 +++++++ src/components/labs/lab-list-filters.ts | 228 +++++++ src/components/labs/lab-list-groups.tsx | 119 +++- src/components/labs/lab-list.tsx | 394 +++++++++--- src/components/labs/types.ts | 3 +- src/components/labs/use-lab-list-filters.ts | 150 +++++ src/components/ui/filter-bar.tsx | 41 +- .../use-url-filter-sync.browser.test.tsx | 30 + src/hooks/use-url-filter-sync.ts | 31 +- .../labs/__tests__/reference-range.test.ts | 21 + src/lib/labs/marker-category.ts | 28 +- src/lib/labs/reference-range.ts | 27 + src/lib/module-list-prefs.ts | 36 +- src/lib/openapi/routes/labs.ts | 4 +- 36 files changed, 3287 insertions(+), 169 deletions(-) create mode 100644 src/components/labs/__tests__/lab-filter-rail.browser.test.tsx create mode 100644 src/components/labs/__tests__/lab-filter-rail.test.tsx create mode 100644 src/components/labs/__tests__/lab-list-filters.test.ts create mode 100644 src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx create mode 100644 src/components/labs/__tests__/lab-list-url-sync.test.tsx create mode 100644 src/components/labs/__tests__/lab-list.test.tsx create mode 100644 src/components/labs/lab-filter-rail.tsx create mode 100644 src/components/labs/lab-list-filters.ts create mode 100644 src/components/labs/use-lab-list-filters.ts diff --git a/.gitignore b/.gitignore index 655bb269e..620d5f46a 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,7 @@ next-env.d.ts # Mutation testing (stryker.config.mjs): reports and the sandbox copy. /reports/mutation /.stryker-tmp + +# Local planning and code-review notes (agent output, never part of the +# public docs surface or the shipped tree). +/plans/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f74563e..6182def9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added + +- **Biomarker list filtering and sorting.** The labs overview list gains + search, reference-range status (out of range, in range, above, below), + category, and date-range filtering alongside on-page sort options. Filter + state is synced to the URL (`?q`, `?status`, `?category`, `?from`, `?to`, + `?sort`) for shareable and navigable views, with local day-boundary alignment, + singular/few/other pluralized counts, and reactive group header counts and + statuses. + ## [1.47.1] — 2026-11-07 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45964de7d..873667926 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -77172,10 +77172,9 @@ components: description: "One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own - spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` - combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else - `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, - the order or the status." + spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `counts` + and `status` describe the full category without filters; a client renders these values directly when unfiltered, + and may summarize the visible row subset client-side while filters are active." LabOverviewCategoryCounts: type: object properties: @@ -77201,7 +77200,8 @@ components: - above - unknown additionalProperties: false - description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range). + description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range) for + the full category without filters. GetLabResultResponse: type: object properties: diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 5b00604e2..1eeb0af91 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -236,15 +236,20 @@ lists) uses the unified `` rail from `src/components/ui/filter-bar.tsx` — never a per-page row of large labelled ` onSearchChange(e.target.value)} + placeholder={t("labs.filter.searchPlaceholder")} + aria-label={t("labs.filter.searchLabel")} + aria-keyshortcuts="/" + className="w-full min-w-0 pr-11 pl-9 [&::-webkit-search-cancel-button]:appearance-none" + /> + {searchDraft === "" ? ( + + ) : ( + + )} +
    + + + + { + if (isLabStatusFilter(val)) onStatusChange(val); + }} + options={statusOptions} + allValue="ALL" + allLabel={t("labs.filter.statusAll")} + /> + + {categoryOptions.length > 0 && ( + + )} + + + + + + + ); +} diff --git a/src/components/labs/lab-list-filters.ts b/src/components/labs/lab-list-filters.ts new file mode 100644 index 000000000..74795159f --- /dev/null +++ b/src/components/labs/lab-list-filters.ts @@ -0,0 +1,228 @@ +"use client"; + +/** + * Pure URL-facet helpers for the labs overview list, following the + * documents-vault and measurements patterns: the list's filter and sort state + * lives in the page URL (?q&status&category&from&to&sort) so a filtered view is + * deep-linkable and survives navigation, reload and back/forward. Parsing is + * lenient — an unknown facet drops silently — and serialisation omits defaults + * so the unfiltered list keeps a clean URL. + */ +import { applyOrder } from "@/lib/module-list-prefs"; +import { + isModuleSortDir, + type ModuleSortDir, +} from "@/lib/settings/module-list-layout"; +import { + dateOnlyKey, + isCalendarDateKey, + isNoonUtcAnchor, +} from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; +import type { MarkerRow } from "./lab-list-rows"; +import type { LabOverviewCategoryDto } from "./types"; + +export const LAB_STATUS_FILTERS = [ + "ALL", + "abnormal", + "in-range", + "below", + "above", +] as const; +export type LabStatusFilter = (typeof LAB_STATUS_FILTERS)[number]; + +export function isLabStatusFilter(value: unknown): value is LabStatusFilter { + return typeof value === "string" && STATUS_SET.has(value); +} + +export interface LabListFilters { + /** Case-insensitive substring search over analyte name and panel. */ + q?: string; + /** Status filter: ALL | abnormal | in-range | below | above. */ + status?: LabStatusFilter; + /** Category key (matches category.key) or ALL. */ + category?: string; + /** Inclusive day bounds, YYYY-MM-DD for newest reading (latest.takenAt). */ + fromDay?: string; + toDay?: string; + /** On-page sort override: recentDesc | recentAsc | alphaAsc | alphaDesc | manual. */ + sort?: ModuleSortDir; +} + +const STATUS_SET = new Set(LAB_STATUS_FILTERS); + +function dayOrUndefined(value: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && isCalendarDateKey(trimmed) ? trimmed : undefined; +} + +/** + * Build a lookup map from markerKey (`bm:` or `analyte:`) to category.key. + */ +export function buildMarkerCategoryMap( + categories: readonly LabOverviewCategoryDto[] | undefined | null, +): Map { + const map = new Map(); + if (!categories) return map; + for (const cat of categories) { + for (const key of cat.markerKeys) { + map.set(key, cat.key); + } + } + return map; +} + +/** + * Parse the labs overview URL search params into the filter object. + */ +export function parseLabListSearchParams( + params: URLSearchParams, +): LabListFilters { + const filters: LabListFilters = {}; + + const q = params.get("q")?.trim(); + if (q) filters.q = q; + + const status = params.get("status")?.trim(); + if (status && isLabStatusFilter(status) && status !== "ALL") { + filters.status = status; + } + + const category = params.get("category")?.trim(); + if (category && category !== "ALL") { + filters.category = category; + } + + const fromDay = dayOrUndefined(params.get("from")); + if (fromDay) filters.fromDay = fromDay; + + const toDay = dayOrUndefined(params.get("to")); + if (toDay) filters.toDay = toDay; + + const sort = params.get("sort")?.trim(); + if (sort && isModuleSortDir(sort)) { + filters.sort = sort; + } + + return filters; +} + +/** + * Serialise the filter object back into the page URL query string (no leading `?`). + * Defaults/ALL values are omitted. + */ +export function labListFiltersToSearch( + filters: LabListFilters, + defaultSort: ModuleSortDir = "recentDesc", +): string { + const sp = new URLSearchParams(); + if (filters.q?.trim()) sp.set("q", filters.q.trim()); + if (filters.status && filters.status !== "ALL") + sp.set("status", filters.status); + if (filters.category && filters.category !== "ALL") { + sp.set("category", filters.category); + } + if (filters.fromDay) sp.set("from", filters.fromDay); + if (filters.toDay) sp.set("to", filters.toDay); + if (filters.sort && filters.sort !== defaultSort) sp.set("sort", filters.sort); + return sp.toString(); +} + +/** + * Resolve the local calendar day key (`YYYY-MM-DD`) for a lab reading. + * + * Data model semantics & date-only heuristic: + * The data model (`LabResult` in Prisma and `LabResultDto`) stores `takenAt` as + * an ISO timestamp and does not have an explicit date-only marker column (e.g. + * `hasTime` or `dateOnly`). Instead, the codebase convention (`@/lib/tz/date-only`) + * stores date-only imports at 12:00:00.000 UTC (`dateOnlyAtNoonUtc`). + * + * Inherent ambiguity: + * An instant reading entered at exactly 12:00:00.000Z is indistinguishable from a + * date-only anchor without schema changes. We preserve the `isNoonUtcAnchor` + * heuristic here so that the day key used for filtering matches exactly what the + * row displays to the user via `useLabDate` (`calendarDate(instant)` for noon-UTC + * anchors, avoiding timezone-shift discrepancies in timezones like UTC+14). + * Timestamps with a time of day resolve in the user's timezone via `userDayKey`. + */ +export function labReadingDayKey(takenAt: string, tz: string): string { + const instant = new Date(takenAt); + if (isNoonUtcAnchor(instant)) { + return dateOnlyKey(instant); + } + return userDayKey(instant, tz); +} + +/** + * Filter rows by text search, reference range status, category, and date bounds. + */ +export function filterMarkerRows( + rows: readonly MarkerRow[], + filters: LabListFilters, + categoryOf?: (markerKey: string) => string | undefined, + dayKeyOf: (takenAt: string) => string = (iso) => iso.slice(0, 10), +): MarkerRow[] { + const query = filters.q?.trim().toLowerCase(); + const status = filters.status; + const category = filters.category; + const fromDay = filters.fromDay; + const toDay = filters.toDay; + + return rows.filter((row) => { + // 1. Text search + if (query) { + const analyteMatch = row.analyte.toLowerCase().includes(query); + const panelMatch = row.panel?.toLowerCase().includes(query) ?? false; + if (!analyteMatch && !panelMatch) return false; + } + + // 2. Reference range status + if (status && status !== "ALL") { + const rowStatus = row.latest.rangeStatus; + if (status === "abnormal") { + if (rowStatus !== "below" && rowStatus !== "above") return false; + } else if (rowStatus !== status) { + return false; + } + } + + // 3. Category — fail closed when categoryOf is unavailable + if (category && category !== "ALL") { + if (!categoryOf) return false; + const markerCategory = categoryOf(row.key); + if (markerCategory !== category) return false; + } + + // 4. Date bounds on latest reading + if (fromDay || toDay) { + const takenDate = dayKeyOf(row.latest.takenAt); + if (fromDay && takenDate < fromDay) return false; + if (toDay && takenDate > toDay) return false; + } + + return true; + }); +} + +/** + * Sort marker rows according to the chosen sort direction and optional manual order. + */ +export function sortMarkerRows( + rows: readonly MarkerRow[], + sortDir: ModuleSortDir, + manualOrder?: readonly string[], +): MarkerRow[] { + if (sortDir === "recentAsc") return [...rows].reverse(); + if (sortDir === "alphaAsc" || sortDir === "alphaDesc") { + const dir = sortDir === "alphaAsc" ? 1 : -1; + return [...rows].sort( + (a, b) => + dir * + a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }), + ); + } + if (sortDir === "manual" && manualOrder && manualOrder.length > 0) { + return applyOrder(rows, manualOrder, (row) => row.biomarkerId ?? row.key); + } + return [...rows]; +} diff --git a/src/components/labs/lab-list-groups.tsx b/src/components/labs/lab-list-groups.tsx index 9d3f89695..ca6e93121 100644 --- a/src/components/labs/lab-list-groups.tsx +++ b/src/components/labs/lab-list-groups.tsx @@ -5,9 +5,11 @@ * header itself, and which categories are open. * * Everything about a category comes from the server (`categories` on - * `GET /api/labs/overview`): its members, label, order, counts and status. - * This module only places the already-sorted rows under them and builds the - * status sentence from the counts; it never decides a category or a status. + * `GET /api/labs/overview`): its members, label, order, counts and status, + * which describe the full category. When no effective list filters are + * active, this module renders those server category counts and status; + * when filters narrow the visible rows, it summarizes the visible subset + * client-side. */ import { useMemo, useState } from "react"; import { ChevronDown } from "lucide-react"; @@ -16,6 +18,7 @@ import { ScorePill } from "@/components/ui/score-pill"; import { TagChip } from "@/components/ui/tag-chip"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; +import { priorityReferenceRangeStatus } from "@/lib/labs/reference-range"; import type { ModuleGroupsOpen } from "@/lib/module-list-prefs"; import { cn } from "@/lib/utils"; @@ -97,12 +100,14 @@ export function writeStoredOpen( } function openByDefault( - category: LabOverviewCategoryDto, mode: ModuleGroupsOpen, + status: LabOverviewCategoryDto["status"], ): boolean { if (mode === "expanded") return true; if (mode === "collapsed") return false; - return category.status === "above" || category.status === "below"; + // Consumers supply category.status for unfiltered categories, or the + // summarised status for filtered groups. + return status === "above" || status === "below"; } /** @@ -112,8 +117,14 @@ function openByDefault( * itself changes. Storage is read only after hydration and may be missing. */ export function useGroupsOpen(mode: ModuleGroupsOpen): { - isOpen: (category: LabOverviewCategoryDto) => boolean; - toggle: (category: LabOverviewCategoryDto) => void; + isOpen: ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => boolean; + toggle: ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => void; } { const mounted = useMounted(); const stored = useMemo( @@ -123,28 +134,71 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): { const [toggled, setToggled] = useState({ mode, open: {} }); const own = toggled.mode === mode ? toggled.open : {}; - const isOpen = (category: LabOverviewCategoryDto) => - own[category.key] ?? stored[category.key] ?? openByDefault(category, mode); - const toggle = (category: LabOverviewCategoryDto) => { - const next = { ...stored, ...own, [category.key]: !isOpen(category) }; + const isOpen = ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => + own[category.key] ?? + stored[category.key] ?? + openByDefault(mode, status); + + const toggle = ( + category: LabOverviewCategoryDto, + status: LabOverviewCategoryDto["status"], + ) => { + const next = { + ...stored, + ...own, + [category.key]: !isOpen(category, status), + }; setToggled({ mode, open: next }); writeStoredOpen(mode, next); }; return { isOpen, toggle }; } -/** The status of a category, from the counts the server sent. */ -function GroupStatus({ category }: { category: LabOverviewCategoryDto }) { +export interface LabGroupSummary { + counts: LabOverviewCategoryDto["counts"]; + status: LabOverviewCategoryDto["status"]; +} + +/** Summarise counts and overall status for a list of rows. */ +export function summariseGroupRows(rows: readonly MarkerRow[]): LabGroupSummary { + const counts = { inRange: 0, below: 0, above: 0, unknown: 0 }; + for (const row of rows) { + const s = row.latest.rangeStatus; + if (s === "in-range") counts.inRange += 1; + else if (s === "below") counts.below += 1; + else if (s === "above") counts.above += 1; + else counts.unknown += 1; + } + const status = priorityReferenceRangeStatus(counts); + return { counts, status }; +} + +/** + * Group-status semantics: + * When filters or search are active, the group summary (both the count badge + * and the range status pill, e.g. "All in range" or "X of Y outside range") + * describes only the visible rows currently matching the filters in this + * group, rather than unfiltered server category totals. + */ +function GroupStatus({ + counts, + status, +}: { + counts: LabOverviewCategoryDto["counts"]; + status: LabOverviewCategoryDto["status"]; +}) { const { t, tCount } = useTranslations(); - if (category.status === "unknown") return null; - if (category.status === "in-range") { + if (status === "unknown") return null; + if (status === "in-range") { return {t("labs.groups.allInRange")}; } - const { inRange, below, above, unknown } = category.counts; return ( - - {tCount("labs.groups.outsideCount", above + below, { - total: inRange + below + above + unknown, + + {tCount("labs.groups.outsideCount", counts.above + counts.below, { + total: counts.inRange + counts.below + counts.above + counts.unknown, })} ); @@ -158,17 +212,40 @@ function GroupStatus({ category }: { category: LabOverviewCategoryDto }) { */ export function LabGroupHeader({ category, + rows, open, onToggle, panelId, className, + isFiltered = false, + status: statusProp, + summary: summaryProp, }: { category: LabOverviewCategoryDto; + rows: readonly MarkerRow[]; open: boolean; onToggle: () => void; panelId: string; className?: string; + isFiltered?: boolean; + status?: LabOverviewCategoryDto["status"]; + summary?: LabGroupSummary; }) { + const { counts, status } = useMemo(() => { + if (isFiltered) { + const summary = summaryProp ?? summariseGroupRows(rows); + return { + counts: summary.counts, + status: statusProp ?? summary.status, + }; + } + const { inRange, below, above, unknown } = category.counts; + return { + counts: { inRange, below, above, unknown }, + status: statusProp ?? category.status, + }; + }, [category, rows, isFiltered, statusProp, summaryProp]); + return (

    diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d2feca236..efb6cb379 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -1,7 +1,7 @@ "use client"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useId, useMemo, useEffect } from "react"; +import { useCallback, useId, useMemo, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { FlaskConical } from "lucide-react"; @@ -11,18 +11,27 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/query-keys"; import { - applyOrder, useModuleListPrefs, - type ModuleListPrefs, + type ModuleListView, + type ModuleSortDir, } from "@/lib/module-list-prefs"; +import { LabFilterRail } from "./lab-filter-rail"; +import { + buildMarkerCategoryMap, + filterMarkerRows, + labReadingDayKey, + sortMarkerRows, +} from "./lab-list-filters"; +import { useLabListFilters } from "./use-lab-list-filters"; import { groupMarkerRows, LabGroupHeader, + summariseGroupRows, useGroupsOpen, type MarkerGroup, } from "./lab-list-groups"; @@ -34,30 +43,10 @@ import { toMarkerRows, type MarkerRow, } from "./lab-list-rows"; -import type { LabOverviewResponse } from "./types"; - -/** - * The person's Labs sort over the server's most-recent-first order: - * `recentAsc` reverses, the alpha options sort by analyte name (#43), and - * `manual` applies the persisted biomarker order (legacy un-linked rows, which - * carry no biomarkerId, sort after the ordered block). Under categories the - * same order holds inside each category. - */ -function sortRows(rows: MarkerRow[], prefs: ModuleListPrefs): MarkerRow[] { - if (prefs.sortDir === "recentAsc") return [...rows].reverse(); - if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") { - const dir = prefs.sortDir === "alphaAsc" ? 1 : -1; - return [...rows].sort( - (a, b) => - dir * - a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }), - ); - } - if (prefs.sortDir === "manual") { - return applyOrder(rows, prefs.order, (row) => row.biomarkerId ?? row.key); - } - return rows; -} +import type { + LabOverviewCategoryDto, + LabOverviewResponse, +} from "./types"; type GroupsOpen = ReturnType; @@ -66,10 +55,12 @@ function LabTable({ rows, groups, groupsOpen, + isFiltered, }: { rows: MarkerRow[]; groups: MarkerGroup[] | null; groupsOpen: GroupsOpen; + isFiltered: boolean; }) { return ( // `lab-list` marks the read results in both view shapes. The loading @@ -84,6 +75,7 @@ function LabTable({ key={group.category.key} group={group} groupsOpen={groupsOpen} + isFiltered={isFiltered} /> )) : rows.map((row) => )} @@ -93,11 +85,15 @@ function LabTable({ ); } -function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) { +function useGroupToggle( + category: LabOverviewCategoryDto, + groupsOpen: GroupsOpen, + status: LabOverviewCategoryDto["status"], +) { const panelId = `${useId()}-panel`; return { - open: groupsOpen.isOpen(group.category), - onToggle: () => groupsOpen.toggle(group.category), + open: groupsOpen.isOpen(category, status), + onToggle: () => groupsOpen.toggle(category, status), panelId, }; } @@ -109,18 +105,33 @@ function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) { function TableGroup({ group, groupsOpen, + isFiltered, }: { group: MarkerGroup; groupsOpen: GroupsOpen; + isFiltered: boolean; }) { - const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen); + const summary = useMemo( + () => (isFiltered ? summariseGroupRows(group.rows) : undefined), + [isFiltered, group.rows], + ); + const status = summary?.status ?? group.category.status; + const { open, onToggle, panelId } = useGroupToggle( + group.category, + groupsOpen, + status, + ); return (
    (isFiltered ? summariseGroupRows(group.rows) : undefined), + [isFiltered, group.rows], + ); + const status = summary?.status ?? group.category.status; + const { open, onToggle, panelId } = useGroupToggle( + group.category, + groupsOpen, + status, + ); return (