diff --git a/CHANGELOG.md b/CHANGELOG.md index 6182def9e..271c0c978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,102 @@ `?sort`) for shareable and navigable views, with local day-boundary alignment, singular/few/other pluralized counts, and reactive group header counts and statuses. +- **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) +- **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. +- **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 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, 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 + 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 + +- **A missed "every N days" checkup now stays due until it is measured.** + A checkup on a cycle of a week or less, such as blood pressure every + two days, moved its next due date on by its cycle the moment its + reminder went out, measured or not. The card then read "in 2 days" + every evening and never "today" or "overdue", and the start page + dropped it. Such a checkup now counts from its last reading: once its + reminder is sent it stays due, then overdue, until a reading lands or + it is marked done, skipped or snoozed, and while it stays open the + reminder comes again every N days, at most once a week, at its usual + hour. A first due date in the past stays overdue past its first + reminder instead of jumping ahead. A checkup that already moved on + corrects itself at its next reminder. + Calendar schedules that repeat within a week, such as a twice-daily + measurement course, still move on to their next time. (#1369) +- **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 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 + 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 + 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/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, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 873667926..2a57864ff 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 @@ -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: @@ -957,9 +965,10 @@ 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; a rotation racing the + subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off." responses: "201": *a13 "401": *a14 @@ -4522,17 +4531,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." @@ -4672,6 +4681,8 @@ paths: - restingHeartRate - heartRateVariability - vo2Max + - ketones + - glucoseKetoneIndex required: true - in: query name: days @@ -4811,6 +4822,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - in: query name: from schema: @@ -5736,6 +5748,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: The metric. required: true description: The metric. @@ -5866,6 +5879,7 @@ paths: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES description: The metric. required: true description: The metric. @@ -16972,12 +16986,13 @@ paths: - MeasurementReminders summary: List Vorsorge reminders (v1.17.1) description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt - ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since - v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is - more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without - a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or - completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it - stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder." + ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. A + rolling reminder (intervalDays, any length), an rrule reminder whose next occurrence is more than seven days + out, and a reminder with no cadence keep their nextDueAt after their reminder is sent, with or without a + measurementType: they stay due, then overdue, until satisfied (a matching reading or lab result, or completed), + skipped or snoozed. While one stays open the server repeats the reminder every intervalDays local days for a + rolling reminder, capped at seven, and every seven local days otherwise. An rrule reminder that recurs within + seven days rolls on to its next slot after a delivered reminder." responses: "200": description: The owner's measurement reminders. @@ -28732,6 +28747,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." @@ -28767,7 +28788,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -28829,7 +28850,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -29709,6 +29730,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number measuredAt: @@ -31443,6 +31465,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES biomarkerId: type: string minLength: 1 @@ -31942,7 +31965,7 @@ components: type: number const: 2 leaves: - maxItems: 111 + maxItems: 112 type: array items: type: string @@ -32587,7 +32610,7 @@ components: - order tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -32643,6 +32666,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -33145,6 +33169,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: @@ -34356,6 +34381,13 @@ components: - expanded - flagged - collapsed + categoryOrder: + maxItems: 200 + type: array + items: + type: string + minLength: 1 + maxLength: 64 required: - version - list @@ -37684,6 +37716,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones description: Which chart's overlays are being written. Closed enum. prefs: type: object @@ -37873,6 +37906,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -38845,7 +38879,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -38901,6 +38935,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -41370,6 +41405,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 +41426,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: @@ -42698,6 +42745,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES value: type: number valueMin: @@ -43466,6 +43514,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" biomarkerName: anyOf: @@ -46064,6 +46113,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -47264,6 +47314,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES - type: "null" intervalDays: anyOf: @@ -47490,6 +47541,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES metricSlot: type: - string @@ -50160,7 +50212,10 @@ 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`. 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 @@ -51012,10 +51067,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: @@ -51062,6 +51119,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 @@ -51119,7 +51201,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: @@ -53805,6 +53890,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES date: type: string format: date @@ -54085,6 +54171,7 @@ components: - TRAINING_READINESS - ACUTE_TRAINING_LOAD - BASAL_ENERGY_BURNED + - BLOOD_KETONES from: type: string format: date @@ -64534,7 +64621,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 63 + maxItems: 64 type: array items: type: object @@ -64590,6 +64677,7 @@ components: - audio-events - daylight - blood-glucose + - ketones - skin-temperature - wrist-temperature - mood @@ -75502,6 +75590,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. @@ -75511,6 +75608,7 @@ components: - sortDir - groupBy - groupsOpen + - categoryOrder - saved additionalProperties: false ModuleListLayoutSavedEnvelope: @@ -77103,8 +77201,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 @@ -88191,6 +88291,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -88457,6 +88558,7 @@ components: - scoreSleep - scoreStress - scoreStrain + - bloodKetones additionalProperties: type: object properties: @@ -93831,6 +93933,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: @@ -94035,6 +94145,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/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/messages/de.json b/messages/de.json index 6a9f4e8d7..5ff259402 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 über null 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.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,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": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Alle {count} Werte", "valuesAllOther": "Alle {count} Werte", "valuesFewer": "Weniger Werte", + "gkiPair": "Blutzucker {glucose} {unit} / Ketone {ketones} {unit}", + "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 cedd35946..70e699ad3 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 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}." + } + }, "totalBodyWater": { "title": "Total Body Water", "description": "Your total body water over time, in kilograms.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Belgium, 24/7, free, anonymous)" + }, + "suicidePrevention109": { + "name": "Suicide prevention line 109 (South Korea, 24/7, free)" } }, "instrumentDescription": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "All {count} values", "valuesAllOther": "All {count} values", "valuesFewer": "Fewer values", + "gkiPair": "Glucose {glucose} {unit} / ketones {ketones} {unit}", + "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 3d49c6f97..07c64f9df 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 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}." + } + }, "totalBodyWater": { "title": "Agua corporal total", "description": "Tu agua corporal total en el tiempo, en kilogramos.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,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": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Los {count} valores", "valuesAllOther": "Los {count} valores", "valuesFewer": "Menos valores", + "gkiPair": "Glucosa {glucose} {unit} / cetonas {ketones} {unit}", + "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 abfad0b49..f09b5c956 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 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}." + } + }, "totalBodyWater": { "title": "Eau corporelle totale", "description": "Votre eau corporelle totale au fil du temps, en kilogrammes.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,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": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Les {count} valeurs", "valuesAllOther": "Les {count} valeurs", "valuesFewer": "Moins de valeurs", + "gkiPair": "Glycémie {glucose} {unit} / cétones {ketones} {unit}", + "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 1c2e7b49a..a7eb70824 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 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}." + } + }, "totalBodyWater": { "title": "Acqua corporea totale", "description": "La tua acqua corporea totale nel tempo, in chilogrammi.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,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": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Tutti i {count} valori", "valuesAllOther": "Tutti i {count} valori", "valuesFewer": "Meno valori", + "gkiPair": "Glicemia {glucose} {unit} / chetoni {ketones} {unit}", + "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 eb23f646b..9ba285dc9 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": "0보다 큰 케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.", + "latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "총 체수분", "description": "시간에 따른 총 체수분이에요(kg).", @@ -10056,6 +10077,17 @@ "DISCREET": "드러나지 않게 (●)", "DESCRIPTIVE": "내용 표시" }, + "forecasts": "예측 표시 방식", + "forecastStyle": { + "EXACT": "예상일만", + "MARGIN": "예상일과 여유 기간", + "BAND": "하나의 기간" + }, + "forecastStyleHint": { + "EXACT": "예상되는 날만 표시합니다.", + "MARGIN": "예상일과 함께, 더 일찍 또는 늦게 시작할 수 있는 날을 별도 일정으로 표시합니다.", + "BAND": "생리가 있을 수 있는 모든 날을 하나의 일정으로 표시합니다." + }, "fertile": "가임기", "fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.", "rotate": "링크 다시 발급", @@ -10075,6 +10107,7 @@ "periodEstimate": "생리 (예상)", "periodLogged": "생리 (기록)", "fertileEstimate": "가임기 (예상)", + "periodPossible": "생리 가능", "likelyStart": "가장 가능성 높은 시작일 {date}, {band}", "bandOne": "±{count}일", "bandFew": "±{count}일", @@ -10584,7 +10617,9 @@ "openExpanded": "모두", "openFlagged": "범위 밖만", "openCollapsed": "없음", - "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요." + "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요.", + "orderHeading": "카테고리 순서", + "orderDefault": "직접 옮기기 전에는 기본 카테고리가 정해진 순서로, 직접 만든 카테고리가 가나다순으로 이어지고 {other}가 마지막에 와요." }, "sourceRange": { "fromReport": "결과지 범위 {range}", @@ -11512,6 +11547,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (벨기에, 24시간, 무료, 익명)" + }, + "suicidePrevention109": { + "name": "자살예방상담전화 109 (24시간, 무료)" } }, "instrumentDescription": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "수치 {count}개 모두", "valuesAllOther": "수치 {count}개 모두", "valuesFewer": "수치 줄여 보기", + "gkiPair": "혈당 {glucose} {unit} / 케톤 {ketones} {unit}", + "gkiCountOne": "{count}개 중 최근", + "gkiCountFew": "{count}개 중 최근", + "gkiCountOther": "{count}개 중 최근", "countEntriesOne": "기록 1개", "countEntriesFew": "기록 {count}개", "countEntriesOther": "기록 {count}개", diff --git a/messages/nl.json b/messages/nl.json index 92f4da749..e87e4ed82 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 boven nul 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.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (België, dag en nacht, gratis, anoniem)" + }, + "suicidePrevention109": { + "name": "Zelfmoordpreventielijn 109 (Zuid-Korea, dag en nacht, gratis)" } }, "instrumentDescription": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Alle {count} waarden", "valuesAllOther": "Alle {count} waarden", "valuesFewer": "Minder waarden", + "gkiPair": "Glucose {glucose} {unit} / ketonen {ketones} {unit}", + "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 7dcbac1e6..66fe659d4 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 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}." + } + }, "totalBodyWater": { "title": "Całkowita woda w organizmie", "description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,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": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Wszystkie {count} wartości", "valuesAllOther": "Wszystkie {count} wartości", "valuesFewer": "Mniej wartości", + "gkiPair": "Glukoza {glucose} {unit} / ketony {ketones} {unit}", + "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 9a3fa1447..a17714148 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 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}." + } + }, "totalBodyWater": { "title": "Água Corporal Total", "description": "Sua água corporal total ao longo do tempo, em quilogramas.", @@ -10056,6 +10077,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 +10107,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", @@ -10584,7 +10617,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}", @@ -11512,6 +11547,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": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Todos os {count} valores", "valuesAllOther": "Todos os {count} valores", "valuesFewer": "Menos valores", + "gkiPair": "Glicose {glucose} {unit} / cetonas {ketones} {unit}", + "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 cb95e408f..a92addfec 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": "Ваша общая вода в организме в динамике, в килограммах.", @@ -10056,6 +10077,17 @@ "DISCREET": "Незаметно (●)", "DESCRIPTIVE": "Подробно" }, + "forecasts": "Вид прогноза", + "forecastStyle": { + "EXACT": "Ожидаемые дни", + "MARGIN": "Ожидаемые дни с запасом", + "BAND": "Один промежуток" + }, + "forecastStyleHint": { + "EXACT": "Только ожидаемые дни.", + "MARGIN": "Ожидаемые дни, а отдельными событиями дни, когда месячные могут начаться раньше или позже.", + "BAND": "Одно событие на все дни, на которые они могут прийтись." + }, "fertile": "Фертильное окно", "fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.", "rotate": "Обновить ссылку", @@ -10075,6 +10107,7 @@ "periodEstimate": "Месячные (оценка)", "periodLogged": "Месячные (записаны)", "fertileEstimate": "Фертильное окно (оценка)", + "periodPossible": "Возможны месячные", "likelyStart": "Наиболее вероятное начало {date}, {band}", "bandOne": "±{count} день", "bandFew": "±{count} дня", @@ -10584,7 +10617,9 @@ "openExpanded": "Все", "openFlagged": "Только вне диапазона", "openCollapsed": "Ни одной", - "orderWithinGroups": "Порядок действует внутри каждой категории." + "orderWithinGroups": "Порядок действует внутри каждой категории.", + "orderHeading": "Порядок категорий", + "orderDefault": "Пока вы ничего не переместили, встроенные категории идут в постоянном порядке, ваши собственные по алфавиту, а {other} в конце." }, "sourceRange": { "fromReport": "Диапазон из отчёта {range}", @@ -11512,6 +11547,9 @@ }, "zelfmoordlijn1813": { "name": "Zelfmoordlijn 1813 (Бельгия, круглосуточно, бесплатно, анонимно)" + }, + "suicidePrevention109": { + "name": "Линия профилактики самоубийств 109 (Южная Корея, круглосуточно, бесплатно)" } }, "instrumentDescription": { @@ -13687,6 +13725,10 @@ "valuesAllFew": "Все {count} значения", "valuesAllOther": "Все {count} значений", "valuesFewer": "Меньше значений", + "gkiPair": "Глюкоза {glucose} {unit} / кетоны {ketones} {unit}", + "gkiCountOne": "Последний из {count}", + "gkiCountFew": "Последний из {count}", + "gkiCountOther": "Последний из {count}", "countEntriesOne": "{count} запись", "countEntriesFew": "Записей: {count}", "countEntriesOther": "Записей: {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/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 8b102b41c..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") } @@ -8343,6 +8347,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 +8404,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__/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__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index adf8c6b01..20b560254 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 523, - "total": 5476, + "total": 5475, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -440,7 +440,7 @@ "lib/jobs/__tests__/insight-status-generate.test.ts": 2, "lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2, "lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2, - "lib/jobs/__tests__/measurement-reminder.test.ts": 33, + "lib/jobs/__tests__/measurement-reminder.test.ts": 32, "lib/jobs/__tests__/medication-intake-import.test.ts": 1, "lib/jobs/__tests__/medication-reminder-check.test.ts": 27, "lib/jobs/__tests__/mood-reminder.test.ts": 15, 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/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index dc52ec8a1..1ddba1dce 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -149,6 +149,8 @@ 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/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__/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/__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/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 9b62851c9..755f171ea 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -4,11 +4,15 @@ * * 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 - * 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, 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 * the guardian of a managed profile (`requireGuardianAuth`), a fresh proof, @@ -33,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"; @@ -70,21 +78,32 @@ 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, historyCycles: true, includeFertile: true, titleMode: true, + 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, @@ -95,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { historyCycles: live.historyCycles, includeFertile: live.includeFertile, titleMode: live.titleMode, + forecastStyle: live.forecastStyle, }, select: FEED_SUMMARY_SELECT, }); @@ -113,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 8b8b3aba9..a91f2d627 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, }, @@ -166,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 = { @@ -180,10 +184,29 @@ 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 }, + // 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, }); @@ -197,12 +220,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, }, }), }, @@ -220,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/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/measurement-reminders/[id]/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts index 28611e5fc..348936784 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -272,8 +272,13 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z")); }); - it("re-enabling a disabled short-cycle reminder schedules it from now", async () => { - const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false }; + it("re-enabling a disabled weekly calendar rule schedules it from now", async () => { + const weekly = { + ...BASE_ROW, + intervalDays: null, + rrule: "FREQ=WEEKLY", + enabled: false, + }; findFirstMock.mockResolvedValue(weekly); const res = await PATCH(makeRequest({ enabled: true }), params); @@ -303,6 +308,26 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n expect("nextDueAt" in persisted).toBe(false); }); + it("re-enabling a missed short rolling reminder keeps its open slot", async () => { + // Every two days, never measured: switching it off and on again is not + // measuring it either. + findFirstMock.mockResolvedValue({ + ...BASE_ROW, + intervalDays: 2, + enabled: false, + }); + + const res = await PATCH(makeRequest({ enabled: true }), params); + expect(res.status).toBe(200); + + const persisted = updateMock.mock.calls[0]?.[0]?.data as Record< + string, + unknown + >; + expect(persisted.enabled).toBe(true); + expect("nextDueAt" in persisted).toBe(false); + }); + it("reads a resent first due date on the same calendar day as unchanged", async () => { // A booster row stores the dose instant plus N days; the web form sends // the same day back as local midnight. Same day in the profile zone, so diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 336e45d68..da5a080d4 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -219,7 +219,7 @@ export const PATCH = apiHandler( const reEnabled = updateData.enabled === true && !existing.enabled; // Re-enabling a reminder that stays due after it is sent keeps the slot it // was on, overdue or not: switching it off and on again is not doing it. - // A short-cycle reminder is scheduled from now, as before. + // A calendar rule that recurs within the week is scheduled from now. const keepsOpenSlot = reEnabled && !cadenceChanged && 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/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 6f126f02c..e9c31b87c 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -31,6 +31,12 @@ * - `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`. + * 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 @@ -43,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/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 && (

", () => { @@ -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/components/day/day-gki-tile.tsx b/src/components/day/day-gki-tile.tsx new file mode 100644 index 000000000..1dd807099 --- /dev/null +++ b/src/components/day/day-gki-tile.tsx @@ -0,0 +1,48 @@ +"use client"; + +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]; + +/** + * 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), + unit: GKI_COMPONENT_UNIT, + })} + + +
  • + ); +} 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/__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/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/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 33263b9ca..5b8ece9a1 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -1406,7 +1406,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place const d = buildDailyDigest( input({ ...crowd, - preventiveDue: [{ label: "Skin check", staysDue: true }], + preventiveDue: [{ label: "Skin check", longCycle: true }], }), t, ); @@ -1416,7 +1416,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place expect(d.worthALook[0].kind).toBe("dose_window"); }); - it("does not pin a short-cycle reminder: it rolls on anyway", () => { + it("does not pin a short-cycle reminder: it comes round within days", () => { const d = buildDailyDigest( input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }), t, @@ -1436,7 +1436,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place const d = buildDailyDigest( input({ ...crowd, - preventiveDue: [{ label: "Skin check", staysDue: true }], + preventiveDue: [{ label: "Skin check", longCycle: true }], upcomingVisits: [visit()], }), t, @@ -1546,7 +1546,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place it("leaves the due check-ups item on the checkups list", () => { const d = buildDailyDigest( input({ - preventiveDue: [{ label: "Skin check", staysDue: true }], + preventiveDue: [{ label: "Skin check", longCycle: true }], upcomingVisits: [visit()], }), t, diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index e6995062f..cfc55729d 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -166,11 +166,11 @@ export interface DailyDigestPreventiveDue { /** Display label, already resolved (a Coach cadence key is translated). */ label: string; /** - * Whether this reminder stays due after it is sent (a cycle longer than a - * week, or none). Only such a reminder earns the check-up item a place - * under the rail cap; a short-cycle rhythm rolls on by itself. + * Whether this reminder is on a cycle longer than a week, or has none + * (`hasLongCycle`). Only such a reminder earns the check-up item a place + * under the rail cap; a short-cycle rhythm comes round again within days. */ - staysDue?: boolean; + longCycle?: boolean; } /** @@ -743,8 +743,8 @@ function buildUpcomingVisitItems( /** * The bounded rail: at most `max` items, in priority order, with the pinned * items guaranteed a place. A pinned item is one a person can miss for good if - * the cap drops it today: a reminder that stays due and is due or overdue - * (its notification has already gone out, and it will not roll on by itself) + * the cap drops it today: a reminder on a long cycle that is due or overdue + * (its notification has already gone out, and the next one is a week away) * and a visit booked for today (the day ends). The rest fill * the remaining slots in order, and the result keeps the priority order, so an * overdue dose still leads. @@ -1207,7 +1207,7 @@ export function buildDailyDigest( // `capWithPinned`); they still sit below a dose in the displayed order. const pinned = new Set( [ - input.preventiveDue.some((due) => due.staysDue) ? preventive : null, + input.preventiveDue.some((due) => due.longCycle) ? preventive : null, visits.today, ].filter((item): item is PriorityItem => item !== null), ); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 1b15616a9..4f334b35d 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -28,7 +28,7 @@ import { import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; -import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open"; +import { hasLongCycle } from "@/lib/measurement-reminders/holds-open"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { isArrivalKind } from "@/lib/arrivals/types"; @@ -592,9 +592,9 @@ export async function loadDailyDigest( // // Read up to the end of the person's local day; the filter below keeps // what is due now, plus what is due later today when it is a reminder - // that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a - // reminder keeps its due date after it is sent, so the overdue ones - // stay here until they are satisfied, skipped or snoozed. + // on a long cycle (see `hasLongCycle`). A reminder that stays due after + // it is sent (`holdsOpenAfterReminder`) keeps its due date, so the + // overdue ones stay here until they are satisfied, skipped or snoozed. where: { userId: user.id, enabled: true, @@ -896,21 +896,21 @@ export async function loadDailyDigest( // the checkups page and the dashboard card translate it, and so does this // line. A free-text label (every other row) is the person's own words. // - // A reminder that stays due after it is sent (a cycle longer than a week, - // a fortnightly questionnaire, a yearly check-up) is on the rail for its - // whole due day and keeps its place under the cap. A short-cycle one (a - // daily weigh-in, a twice-daily course) is a rhythm: it shows only once its - // time has come, is not pinned, and its next reading moves it on. + // A reminder on a cycle longer than a week (a fortnightly questionnaire, a + // yearly check-up) is on the rail for its whole due day and keeps its place + // under the cap. A short-cycle one (a daily weigh-in, a twice-daily course) + // is a rhythm: it shows only once its time has come and is not pinned; a + // missed rolling one then stays here, overdue, until its reading lands. const preventiveDue: DailyDigestPreventiveDue[] = []; for (const row of dueReminders) { if (row.nextDueAt === null) continue; - const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt); - if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue; + const longCycle = hasLongCycle(row, user.timezone, row.nextDueAt); + if (!longCycle && row.nextDueAt.getTime() > now.getTime()) continue; let label = row.label; if (row.origin === "COACH") { label = translateOrNull(t, row.label) ?? label; } - preventiveDue.push({ label, staysDue }); + preventiveDue.push({ label, longCycle }); } // Group the two figures for the reader. `Intl.NumberFormat` is the only 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/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts index a637df428..e18ee8f8c 100644 --- a/src/lib/documents/__tests__/extract.test.ts +++ b/src/lib/documents/__tests__/extract.test.ts @@ -193,6 +193,39 @@ 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]); + // 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 () => { // 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..54000da5f 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 ?? "", @@ -387,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/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/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 1592377f7..4670b9a83 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -9,11 +9,12 @@ * logged since the last satisfy advances lastSatisfiedAt + recomputes * nextDueAt and suppresses the nudge. Free-text reminders never * auto-resolve. - * - A reminder on a weekly or shorter cycle rolls on: a successful - * dispatch advances nextDueAt past now. One on a longer cycle, or a - * one-shot, does NOT roll on (measurement reminder or free text): it - * stays due until it is satisfied, and a second nudge for the same open - * slot waits a week. Appointments stay one-shot. + * - A calendar rule recurring within the week rolls on: a successful + * dispatch advances nextDueAt past now. A rolling interval of any + * length, a rule on a longer cycle, or a one-shot does NOT roll on + * (measurement reminder or free text): it stays due until it is + * satisfied, and a second nudge for the same open slot waits its + * interval, at most a week. Appointments stay one-shot. * - clientManaged suppresses the APNs leg only: the tick still * dispatches, and a reminder nothing delivered stays overdue. * @@ -58,6 +59,7 @@ beforeEach(() => { // 09:00 Berlin in June = 07:00Z. const NINE_LOCAL = new Date("2026-06-15T07:00:00Z"); +const DAY = 24 * 60 * 60 * 1000; describe("evaluateMeasurementReminderDue — window boundary", () => { const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) }; @@ -216,6 +218,16 @@ function makePrisma(opts: { return { prisma, updates }; } +/** One tick against the stubbed Prisma surface: the one cast in this file's + * newer cases, rather than one per call. */ +function tick( + prisma: ReturnType["prisma"], + now: Date, + options: Parameters[2] = {}, +) { + return runMeasurementReminderTick(prisma as never, now, options); +} + function reminder(overrides: Partial): FakeReminder { return { id: "r1", @@ -312,7 +324,7 @@ describe("runMeasurementReminderTick", () => { it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => { const { prisma, updates } = makePrisma({ - reminders: [reminder({})], + reminders: [reminder({ intervalDays: null, rrule: "FREQ=WEEKLY" })], measurementMatch: null, }); const dispatch = vi.fn(async () => OK); @@ -662,9 +674,15 @@ describe("runMeasurementReminderTick", () => { expect("nextDueAt" in updates[0].data).toBe(false); }); - it("still rolls a weekly measurement reminder on after its reminder", async () => { + it("keeps a Coach-suggested daily measurement reminder due after its reminder", async () => { const { prisma, updates } = makePrisma({ - reminders: [reminder({ origin: "COACH" })], + reminders: [ + reminder({ + origin: "COACH", + measurementType: "WEIGHT", + intervalDays: 1, + }), + ], measurementMatch: null, }); const dispatch = vi.fn(async () => OK); @@ -672,9 +690,7 @@ describe("runMeasurementReminderTick", () => { await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); expect(updates).toHaveLength(1); - const advanced = updates[0].data.nextDueAt as Date; - expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); - expect(updates[0].data.lastNotifiedAt).toEqual(NINE_LOCAL); + expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); it("still closes an appointment reminder after its one nudge", async () => { @@ -728,11 +744,9 @@ describe("runMeasurementReminderTick", () => { const sixDays = makePrisma({ reminders: [open], labMatch: null }); const heldDispatch = vi.fn(async () => OK); - await runMeasurementReminderTick( - sixDays.prisma as never, - new Date("2026-06-14T07:00:00Z"), - { dispatch: heldDispatch }, - ); + await tick(sixDays.prisma, new Date("2026-06-14T07:00:00Z"), { + dispatch: heldDispatch, + }); expect(heldDispatch).not.toHaveBeenCalled(); // Seven calendar days on, at the same notify hour — a few seconds EARLIER @@ -740,11 +754,7 @@ describe("runMeasurementReminderTick", () => { // as "under a week". const sevenDays = makePrisma({ reminders: [open], labMatch: null }); const dispatch = vi.fn(async () => OK); - const summary = await runMeasurementReminderTick( - sevenDays.prisma as never, - NINE_LOCAL, - { dispatch }, - ); + const summary = await tick(sevenDays.prisma, NINE_LOCAL, { dispatch }); expect(dispatch).toHaveBeenCalledTimes(1); expect(summary.dispatched).toBe(1); expect(sevenDays.updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); @@ -783,7 +793,7 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); - it("rolls a weekly check-up on, since its next slot is the next nudge", async () => { + it("keeps a weekly rolling check-up due: its next slot needs it done first", async () => { const { prisma, updates } = makePrisma({ reminders: [checkup({ rrule: null, intervalDays: 7 })], labMatch: null, @@ -792,8 +802,7 @@ describe("runMeasurementReminderTick", () => { await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); - const advanced = updates[0].data.nextDueAt as Date; - expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); + expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); it("keeps a fortnightly check-up due", async () => { @@ -952,3 +961,160 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt); }); }); + +// ── A rolling reminder stays due until it is measured ──────────────────── +// +// The reported shape: blood pressure every two days at 18:00, first due a +// month back, never measured since. The old tick rolled it two days on the +// moment its reminder was sent, so the card read "in 2 days" every evening +// and never "today" or "overdue". +describe("runMeasurementReminderTick — a missed rolling reminder", () => { + // 18:00 in Berlin in November (UTC+1). + const SIX_PM = new Date("2026-11-08T17:00:00.000Z"); + const twoDay = (overrides: Partial = {}) => + reminder({ + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 2, + notifyHour: 18, + // First due 4 October, local midnight; never satisfied since. + anchorDate: new Date("2026-10-03T22:00:00.000Z"), + nextDueAt: new Date("2026-10-04T16:00:00.000Z"), + createdAt: new Date("2026-11-08T09:00:00.000Z"), + ...overrides, + }); + + it("keeps a two-day reminder due after its reminder instead of rolling it on", async () => { + const { prisma, updates } = makePrisma({ + reminders: [twoDay()], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await tick(prisma, SIX_PM, { + dispatch, + }); + + expect(summary.dispatched).toBe(1); + expect(updates).toHaveLength(1); + expect(updates[0].data).toEqual({ lastNotifiedAt: SIX_PM }); + }); + + it("keeps a daily and a weekly rolling reminder due the same way", async () => { + for (const intervalDays of [1, 7]) { + const { prisma, updates } = makePrisma({ + reminders: [twoDay({ intervalDays })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await tick(prisma, SIX_PM, { dispatch }); + + expect(updates[0].data, `every ${intervalDays} days`).toEqual({ + lastNotifiedAt: SIX_PM, + }); + } + }); + + it("repeats an open two-day reminder every second day, not every seventh", async () => { + const open = twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) }); + + const nextDay = makePrisma({ reminders: [open], measurementMatch: null }); + const heldDispatch = vi.fn(async () => OK); + const held = await tick( + nextDay.prisma, + new Date(SIX_PM.getTime() + 1 * DAY), + { dispatch: heldDispatch }, + ); + expect(held.skippedRepeatHeld).toBe(1); + expect(heldDispatch).not.toHaveBeenCalled(); + + const twoDaysOn = makePrisma({ reminders: [open], measurementMatch: null }); + const dispatch = vi.fn(async () => OK); + const at = new Date(SIX_PM.getTime() + 2 * DAY); + const sent = await tick(twoDaysOn.prisma, at, { dispatch }); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(sent.dispatched).toBe(1); + // Still open: the due date stays where it was. + expect(twoDaysOn.updates[0].data).toEqual({ lastNotifiedAt: at }); + }); + + it("repeats an open daily reminder the next day", async () => { + const { prisma } = makePrisma({ + reminders: [ + twoDay({ + intervalDays: 1, + lastNotifiedAt: new Date(SIX_PM.getTime() - 1 * DAY + 4_000), + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await tick(prisma, SIX_PM, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it("still holds a long rolling cycle's repeat to a week", async () => { + const { prisma } = makePrisma({ + reminders: [ + twoDay({ + intervalDays: 30, + lastNotifiedAt: new Date(SIX_PM.getTime() - 6 * DAY + 4_000), + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await tick(prisma, SIX_PM, { + dispatch, + }); + + expect(summary.skippedRepeatHeld).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("moves on from the reading that satisfies it, two days from the reading", async () => { + const measuredAt = new Date("2026-11-09T07:30:00.000Z"); + const { prisma, updates } = makePrisma({ + reminders: [ + twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) }), + ], + measurementMatch: { measuredAt }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await tick(prisma, new Date(SIX_PM.getTime() + 1 * DAY), { + dispatch, + }); + + expect(summary.autoResolved).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt); + // 11 November, 18:00 in Berlin. + expect(updates[0].data.nextDueAt).toEqual( + new Date("2026-11-11T17:00:00.000Z"), + ); + }); + + it("still rolls a weekly calendar rule on after its reminder", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + twoDay({ + intervalDays: null, + rrule: "FREQ=WEEKLY", + nextDueAt: SIX_PM, + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await tick(prisma, SIX_PM, { dispatch }); + + const advanced = updates[0].data.nextDueAt as Date; + expect(advanced.getTime()).toBeGreaterThan(SIX_PM.getTime()); + expect(updates[0].data.lastNotifiedAt).toEqual(SIX_PM); + }); +}); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index b8997bb2e..05a8f14d9 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -9,19 +9,20 @@ * * Differences from the mood reminder: * - * 1. What a delivered reminder does to the row depends on its cycle. - * A reminder whose next slot is more than a week away (a fortnightly - * questionnaire, a yearly check-up) or that has no next slot at all (a - * one-shot) stays due until it is satisfied, skipped or snoozed, so the - * start page and the check-up card keep showing it as due and then - * overdue; rolling it on silently filed an open task under a later - * slot. While it stays open it is reminded again at most once a week, - * gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on - * a weekly or shorter cycle rolls on to its next slot, which is the - * next nudge anyway, and an appointment (`origin: ENCOUNTER`) is - * one-shot and closes (see `holdsOpenAfterReminder`). The per-day claim - * in `notification_events` stays the guard against a second send on - * the same local day. + * 1. What a delivered reminder does to the row depends on its cadence. + * A rolling reminder ("every N days", anchored on its last reading), + * a calendar rule whose next slot is more than a week away (a yearly + * check-up), and a reminder with no next slot at all (a one-shot) stay + * due until they are satisfied, skipped or snoozed, so the start page + * and the check-up card keep showing them as due and then overdue; + * rolling them on silently filed an open task under a later slot. While + * one stays open it is reminded again every N days for a rolling + * interval, at most once a week, gated on `lastNotifiedAt` (see + * `isCheckupRepeatHeld`). A calendar rule that recurs within the week + * rolls on to its next slot, which is the next nudge anyway, and an + * appointment (`origin: ENCOUNTER`) is one-shot and closes (see + * `holdsOpenAfterReminder`). The per-day claim in `notification_events` + * stays the guard against a second send on the same local day. * 2. Auto-resolve from an incoming measurement. Before deciding to * fire, the runner checks whether a matching reading of the * reminder's `measurementType` has landed since the last satisfy @@ -105,31 +106,49 @@ function moduleForMeasurementType( */ const DUE_QUERY_SLACK_MS = 15 * 60_000; -/** Calendar days an open reminder waits before it is sent again. */ +/** Calendar days an open reminder waits at most before it is sent again. */ export const CHECKUP_REPEAT_DAYS = 7; +/** + * Calendar days an open reminder waits before it is sent again: its own + * interval for a rolling reminder, capped at {@link CHECKUP_REPEAT_DAYS}, so + * a missed every-two-days reading is asked for again every second day rather + * than once a week; a week for everything else that stays open. + */ +function checkupRepeatDays(reminder: { intervalDays?: number | null }): number { + const interval = reminder.intervalDays ?? null; + if (interval === null) return CHECKUP_REPEAT_DAYS; + return Math.max(1, Math.min(interval, CHECKUP_REPEAT_DAYS)); +} + /** * v1.39.2 — whether an open check-up's repeat nudge is still being held. * * Held when the last delivered nudge was for THIS slot (`lastNotifiedAt` at - * or after `nextDueAt`) and fewer than {@link CHECKUP_REPEAT_DAYS} calendar + * or after `nextDueAt`) and fewer than {@link checkupRepeatDays} calendar * days have passed in the person's timezone. Calendar days rather than a - * 7 × 24 h gap, because the tick fires within the notify hour and the next - * week's tick can land a few seconds earlier on the clock than the first - * send did. + * N × 24 h gap, because the tick fires within the notify hour and the next + * tick can land a few seconds earlier on the clock than the first send did. * * A slot that moved after the last nudge — a snooze to a named day, a * Telegram "later", a skip, a satisfy — is a new slot and is never held. */ export function isCheckupRepeatHeld( - reminder: { nextDueAt: Date | null; lastNotifiedAt?: Date | null }, + reminder: { + nextDueAt: Date | null; + lastNotifiedAt?: Date | null; + intervalDays?: number | null; + }, timezone: string, now: Date, ): boolean { const { nextDueAt, lastNotifiedAt } = reminder; if (!nextDueAt || !lastNotifiedAt) return false; if (lastNotifiedAt.getTime() < nextDueAt.getTime()) return false; - return calendarDaysUntil(now, lastNotifiedAt, timezone) < CHECKUP_REPEAT_DAYS; + return ( + calendarDaysUntil(now, lastNotifiedAt, timezone) < + checkupRepeatDays(reminder) + ); } export interface MeasurementReminderSummary { @@ -341,9 +360,9 @@ export async function runMeasurementReminderTick( // there is nothing it could do this tick, so skip it before the // auto-resolve read: the eventful `reminder-satisfy` worker resolves it // the moment a reading or lab result lands, and the safety-net poll - // below still runs for it once a day, in the notify hour. The weekly + // below still runs for it once a day, in the notify hour. The repeat // hold stays after that poll on purpose, so the safety net is daily - // rather than weekly. + // rather than once per repeat. if ( reminder.lastNotifiedAt != null && reminder.nextDueAt !== null && @@ -405,8 +424,8 @@ export async function runMeasurementReminderTick( } // v1.39.2 — an open check-up stays due after its nudge, so without - // this it would qualify again at every notify hour. Once a week is the - // repeat; the per-day claim below is not enough on its own. + // this it would qualify again at every notify hour. Its interval, at + // most a week, is the repeat; the per-day claim is not enough alone. if ( reminder.origin !== "ENCOUNTER" && isCheckupRepeatHeld(reminder, timezone, now) @@ -529,9 +548,9 @@ export async function runMeasurementReminderTick( continue; } - // Dispatch succeeded. Record the delivery; a check-up keeps its due - // date (it is still open, and `lastNotifiedAt` holds the repeat to a - // week), everything else rolls on past this slot. + // Dispatch succeeded. Record the delivery; a reminder that stays due + // keeps its due date (it is still open, and `lastNotifiedAt` holds the + // repeat), a short calendar rule rolls on past this slot. await prisma.measurementReminder.update({ where: { id: reminder.id }, data: holdsOpenAfterReminder(reminder, timezone, now) @@ -703,8 +722,9 @@ function nextSlotAfterReminder( // as-is is correct. For a ROLLING reminder the engine anchors the // first-due slot AT `anchorDate ?? createdAt` when never satisfied, which // stays ≤ now and would re-fire every tick — so re-anchor the rolling - // cadence on `now` (a fire is the rhythm restarting from this dispatch) to - // roll it forward by exactly one interval. + // cadence on `now` to roll it forward by exactly one interval. A delivered + // rolling reminder never comes here (it stays due); the rolling arm serves + // the disabled-module skip, where nothing is left to deliver. const rolling = reminder.intervalDays !== null; const scheduleInput: ReminderScheduleInput = { intervalDays: reminder.intervalDays, 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/__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/marker-category.ts b/src/lib/labs/marker-category.ts index aa055f536..ca0993b72 100644 --- a/src/lib/labs/marker-category.ts +++ b/src/lib/labs/marker-category.ts @@ -191,14 +191,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< @@ -264,5 +297,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/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); diff --git a/src/lib/measurement-reminders/__tests__/holds-open.test.ts b/src/lib/measurement-reminders/__tests__/holds-open.test.ts index 6c63f2d8f..3e21ab107 100644 --- a/src/lib/measurement-reminders/__tests__/holds-open.test.ts +++ b/src/lib/measurement-reminders/__tests__/holds-open.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { HOLD_OPEN_MIN_CYCLE_DAYS, + hasLongCycle, holdsOpenAfterReminder, } from "../holds-open"; @@ -35,13 +36,17 @@ describe("holdsOpenAfterReminder", () => { expect(holdsOpenAfterReminder(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true); }); - it("lets a weekly or shorter interval roll on", () => { - expect(holdsOpenAfterReminder(r({ intervalDays: 7 }), TZ, SLOT)).toBe( - false, - ); - expect(holdsOpenAfterReminder(r({ intervalDays: 1 }), TZ, SLOT)).toBe( - false, - ); + // A rolling interval is anchored on its last satisfaction, so its next + // slot only exists once it is measured: rolling it on after its reminder + // re-anchored it on the send instead, and a missed every-two-days blood + // pressure reading read "in 2 days" each evening and never "overdue". + it("holds every rolling interval open, however short", () => { + for (const intervalDays of [1, 2, 3, 7]) { + expect( + holdsOpenAfterReminder(r({ intervalDays }), TZ, SLOT), + `every ${intervalDays} days`, + ).toBe(true); + } }); it("reads a rule by the gap to its next occurrence", () => { @@ -109,3 +114,32 @@ describe("holdsOpenAfterReminder", () => { ).toBe(false); }); }); + +// The digest's narrower question: whether a reminder is infrequent enough to +// sit on the start page for its whole due day. A daily weigh-in at 20:00 is +// not, even though it now stays due once its reminder is sent. +describe("hasLongCycle", () => { + it("reads a rolling interval by its length", () => { + expect(hasLongCycle(r({ intervalDays: 1 }), TZ, SLOT)).toBe(false); + expect(hasLongCycle(r({ intervalDays: 7 }), TZ, SLOT)).toBe(false); + expect(hasLongCycle(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true); + }); + + it("agrees with holdsOpenAfterReminder on every rule and on no cadence", () => { + for (const rrule of [ + "FREQ=YEARLY;INTERVAL=1", + "FREQ=WEEKLY", + "FREQ=DAILY;BYHOUR=9,19", + "FREQ=DAILY;UNTIL=20260926T235959Z", + null, + ]) { + expect(hasLongCycle(r({ rrule }), TZ, SLOT), String(rrule)).toBe( + holdsOpenAfterReminder(r({ rrule }), TZ, SLOT), + ); + } + }); + + it("never counts an appointment", () => { + expect(hasLongCycle(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe(false); + }); +}); diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts index 78c85990f..2c534bab7 100644 --- a/src/lib/measurement-reminders/__tests__/scheduling.test.ts +++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts @@ -8,8 +8,10 @@ import { describe, it, expect } from "vitest"; import { byHourTimesOfDay, computeReminderNextDueAt, + parseReminderAnchor, type ReminderScheduleInput, } from "../scheduling"; +import { relativeDueKey } from "../due-day"; const TZ = "Europe/Berlin"; @@ -52,6 +54,26 @@ describe("computeReminderNextDueAt", () => { expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z"); }); + // The create and edit routes compute `nextDueAt` with exactly this call. + // A first due date weeks back, never measured since, stays on that day, so + // the card reads it as overdue: it is not walked forward past now. + it("rolling, never satisfied, first due weeks back: stays on the anchor day", () => { + const now = new Date("2026-11-08T19:40:00Z"); // 20:40 in Berlin + const reminder = base({ + intervalDays: 2, + notifyHour: 18, + anchorDate: parseReminderAnchor("2026-10-04", TZ), + createdAt: now, + }); + const next = computeReminderNextDueAt(reminder, TZ, now); + // 4 October, 18:00 in Berlin (UTC+2). + expect(next).toEqual(new Date("2026-10-04T16:00:00Z")); + expect(relativeDueKey(next!.toISOString(), now.getTime(), TZ)).toEqual({ + key: "measurementReminders.overdueByDays", + days: 35, + }); + }); + it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => { const reminder = base({ intervalDays: 7, diff --git a/src/lib/measurement-reminders/holds-open.ts b/src/lib/measurement-reminders/holds-open.ts index f66138528..aae9cef64 100644 --- a/src/lib/measurement-reminders/holds-open.ts +++ b/src/lib/measurement-reminders/holds-open.ts @@ -1,22 +1,32 @@ /** * v1.39.2 — whether a reminder stays due after it is sent. * - * Decided by the cycle length, not by what the reminder asks for. A reminder - * whose next slot is more than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days after the - * current one stays due until it is satisfied (a matching reading or lab - * result, or "done"), skipped or snoozed: rolling it on would file an open - * task under a slot weeks or a year away. That holds for free-text check-ups - * and measurement reminders alike; the report that led here was a fortnightly - * PHQ-9 and GAD-7 pair. A reminder with no cadence, or a rule with no - * occurrence left, has no next slot and also stays due: its last slot is the - * task. A weekly or shorter cycle rolls on, because its next slot is the next - * reminder anyway, and holding a daily course on a missed slot would stall it. - * An appointment (`ENCOUNTER`) is always one-shot. + * A reminder that stays due is not moved by its reminder: it keeps its due + * date until it is satisfied (a matching reading or lab result, or "done"), + * skipped or snoozed, so every surface reads it as due and then overdue. + * + * - A rolling interval (`intervalDays`) always stays due. It is anchored on + * its last satisfaction, so its next slot does not exist until it is + * satisfied; rolling it on after a reminder re-anchored it on the send, + * and a missed every-two-days blood pressure reading read "in 2 days" + * each evening and was never shown as due or overdue. + * - A calendar rule (`rrule`) stays due when its next occurrence is more + * than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days away: rolling it on would + * file an open task under a slot weeks or a year away. That holds for + * free-text check-ups and measurement reminders alike; the report that + * led here was a fortnightly PHQ-9 and GAD-7 pair. A rule that recurs + * within the week rolls on, because its next occurrence is the next + * reminder anyway and is fixed by the calendar, not by the last reading; + * holding a twice-daily Coach course on a missed slot would stall it. + * - A reminder with no cadence, or a rule with no occurrence left, has no + * next slot and stays due: its last slot is the task. + * - An appointment (`ENCOUNTER`) is always one-shot. * * The one definition. The reminder tick decides with it whether a delivered - * reminder rolls on; the daily digest decides with it whether a reminder due - * later today belongs on the start page already and keeps its place there; - * the edit route decides with it whether re-enabling keeps the open slot. + * reminder rolls on; the edit route decides with it whether re-enabling keeps + * the open slot. The daily digest asks the narrower question underneath it, + * {@link hasLongCycle}: whether a reminder is infrequent enough to sit on the + * start page for its whole due day. * * A course window (`endsOn`) is deliberately not consulted: it ends a * Coach-suggested measurement course, which is a short-cycle rhythm and rolls @@ -25,7 +35,7 @@ import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; -/** A cycle of at most this many calendar days rolls on after its reminder. */ +/** A calendar rule recurring within this many calendar days rolls on after its reminder. */ export const HOLD_OPEN_MIN_CYCLE_DAYS = 7; export interface HoldOpenInput { @@ -41,15 +51,34 @@ export interface HoldOpenInput { /** * `slot` is the instant of the current slot: the send instant in the tick * (a few seconds past the occurrence it sends), the stored `nextDueAt` - * elsewhere. For a rule the cycle is the calendar-day gap between its next - * two occurrences from `slot` on, so the answer does not depend on where - * between two occurrences `slot` sits. With one occurrence left the gap is - * measured from `slot` to it; with none left the reminder holds open. + * elsewhere. */ export function holdsOpenAfterReminder( reminder: HoldOpenInput, timezone: string, slot: Date, +): boolean { + if (reminder.origin === "ENCOUNTER") return false; + if (reminder.intervalDays !== null) return true; + return hasLongCycle(reminder, timezone, slot); +} + +/** + * Whether the reminder's cycle is longer than {@link HOLD_OPEN_MIN_CYCLE_DAYS} + * days, or it has no next slot at all. The daily digest puts such a reminder + * on the start page from the morning of its due day and keeps it there under + * the rail cap; a short rhythm (a daily weigh-in at 20:00) waits until its + * time has come. + * + * For a rule the cycle is the calendar-day gap between its next two + * occurrences from `slot` on, so the answer does not depend on where between + * two occurrences `slot` sits. With one occurrence left the gap is measured + * from `slot` to it; with none left the cycle counts as long. + */ +export function hasLongCycle( + reminder: HoldOpenInput, + timezone: string, + slot: Date, ): boolean { if (reminder.origin === "ENCOUNTER") return false; if (reminder.intervalDays !== null) { 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..27ead05d2 --- /dev/null +++ b/src/lib/measurements/__tests__/glucose-ketone-index.test.ts @@ -0,0 +1,168 @@ +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("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)], + [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..50e130994 --- /dev/null +++ b/src/lib/measurements/glucose-ketone-index.ts @@ -0,0 +1,193 @@ +/** + * 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[] { + // 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.id.localeCompare(b.id), + ); + 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..bb17fb7a6 --- /dev/null +++ b/src/lib/measurements/read-glucose-ketone-index.ts @@ -0,0 +1,84 @@ +/** + * 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, + 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`; + // 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 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)); +} 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/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; } diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index 419ba3a2d..e95df8bbc 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 }); + } } } @@ -252,6 +281,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(); @@ -283,6 +313,7 @@ export function useModuleListPrefs(list: ModuleListKey): { sortDir: entry.sortDir, groupBy: entry.groupBy, groupsOpen: entry.groupsOpen, + categoryOrder: entry.categoryOrder, }; } const defaults = moduleListDefaults(list); @@ -293,6 +324,7 @@ export function useModuleListPrefs(list: ModuleListKey): { sortDir: entry.sortDir, groupBy: entry.groupBy, groupsOpen: entry.groupsOpen, + categoryOrder: entry.categoryOrder, } : defaults; if (!mounted) return fallback; @@ -328,6 +360,11 @@ export function useModuleListPrefs(list: ModuleListKey): { [write], ); + const setCategoryOrder = useCallback( + (categoryOrder: string[]) => write({ categoryOrder }), + [write], + ); + return { prefs, isLoaded, @@ -336,5 +373,6 @@ export function useModuleListPrefs(list: ModuleListKey): { setSortDir, setGroupBy, setGroupsOpen, + setCategoryOrder, }; } 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/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index 3e250c994..4648b1c1a 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": { @@ -215,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 } }, @@ -233,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, @@ -271,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. 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/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index bb2ae8d8c..d2ea27ed3 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/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index a3e94af1a..d17a2bc00 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable = tags: ["MeasurementReminders"], summary: "List Vorsorge reminders (v1.17.1)", description: - "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.", + "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. A rolling reminder (intervalDays, any length), an rrule reminder whose next occurrence is more than seven days out, and a reminder with no cadence keep their nextDueAt after their reminder is sent, with or without a measurementType: they stay due, then overdue, until satisfied (a matching reading or lab result, or completed), skipped or snoozed. While one stays open the server repeats the reminder every intervalDays local days for a rolling reminder, capped at seven, and every seven local days otherwise. An rrule reminder that recurs within seven days rolls on to its next slot after a delivered reminder.", responses: { ...recordRefusal(), "200": { 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/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/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 3a6cb02ab..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() @@ -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`. 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() .describe( 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/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts index afc43656c..08affe190 100644 --- a/src/lib/settings/__tests__/module-list-layout.test.ts +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { + MODULE_LIST_CATEGORY_ORDER_MAX, MODULE_LIST_ORDER_MAX, normaliseModuleListOrder, resolveModuleListLayout, storedModuleListLayout, + mergeCategoryOrder, } from "../module-list-layout"; describe("resolveModuleListLayout", () => { @@ -16,6 +18,7 @@ describe("resolveModuleListLayout", () => { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: false, }); expect(layout.lists.illness.view).toBe("cards"); @@ -34,6 +37,7 @@ describe("resolveModuleListLayout", () => { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: true, }); expect(layout.lists.labs.saved).toBe(false); @@ -50,6 +54,7 @@ describe("resolveModuleListLayout", () => { sortDir: "recentDesc", groupBy: "none", groupsOpen: "expanded", + categoryOrder: [], saved: false, }); expect(layout.lists.vorsorge.saved).toBe(false); @@ -70,11 +75,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( @@ -101,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 aa7cf8881..8a0d3c0a7 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,29 @@ 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; + +/** + * 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; @@ -58,6 +86,7 @@ export interface ModuleListPrefs { sortDir: ModuleSortDir; groupBy: ModuleGroupBy; groupsOpen: ModuleGroupsOpen; + categoryOrder: string[]; } export interface ModuleListPrefsEntry extends ModuleListPrefs { @@ -76,6 +105,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 +117,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 +153,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 +166,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 +185,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 +200,7 @@ export function storedModuleListChoices( ...(isModuleGroupsOpen(blob.groupsOpen) ? { groupsOpen: blob.groupsOpen } : {}), + ...(categoryOrder !== null ? { categoryOrder } : {}), }; } 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/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 49dc08c7d..0735a647b 100644 --- a/src/lib/sync/changes/pull.ts +++ b/src/lib/sync/changes/pull.ts @@ -14,6 +14,7 @@ import { decodeCursorIssuedAt, encodeCursor, encodeCursorAtNow, + drainedWatermark, SYNC_DOMAINS, type SyncCursor, type SyncDomain, @@ -110,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,6 +234,39 @@ export async function pullSyncChanges(input: { cy.hasMore || mm.hasMore; + // 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" }, meta: { diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index ab2fa1694..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 @@ -129,6 +132,27 @@ export function encodeCursorAtNow(nowMs: number): string { return encodeCursor(cursor, nowMs); } +/** + * The watermark for a domain whose page has run dry: `START_NOW_OVERLAP_MS` + * before `nowMs`, whichever way that moves it. + * + * 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 drainedWatermark(nowMs: number): DomainWatermark { + return { updatedAtMs: nowMs - START_NOW_OVERLAP_MS, 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/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/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/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/__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/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/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/checkup-stays-due.test.ts b/tests/integration/checkup-stays-due.test.ts index 8c2dd23e9..86ca9926f 100644 --- a/tests/integration/checkup-stays-due.test.ts +++ b/tests/integration/checkup-stays-due.test.ts @@ -8,6 +8,10 @@ * it is reminded once, keeps its due date, shows on the start page's * digest, is not reminded again the same day or the next, is reminded * once more seven days on, and moves to next year when it is marked done. + * A missed every-two-days blood pressure reading, first due a month + * back, does the same on its own rhythm: due and overdue after each + * reminder, asked for again every second day, two days on from the + * reading that lands. * 2. **Migration 0355's repair.** The container has every migration applied * already, so the data steps are pulled out of the `.sql` file and run * again against rows seeded the way an upgrading instance holds them: @@ -24,6 +28,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Prisma } from "@/generated/prisma/client"; import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { + computeReminderNextDueAt, + parseReminderAnchor, +} from "@/lib/measurement-reminders/scheduling"; +import { + isOverdueDue, + relativeDueKey, +} from "@/lib/measurement-reminders/due-day"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; @@ -239,6 +251,97 @@ describe("an open fortnightly questionnaire through the reminder tick", () => { }); }); +describe("a missed every-two-days reading through the reminder tick", () => { + // 18:00 in Berlin in November (UTC+1). + const SIX_PM = new Date("2026-11-08T17:00:00.000Z"); + + it("stays due and overdue, repeats every second day, and moves on from the reading", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("bp-owner"); + // Created this morning with a first due date a month back, as the form + // does; the due date is the one the create route computes. + const createdAt = new Date("2026-11-08T09:00:00.000Z"); + const schedule = { + intervalDays: 2, + rrule: null, + anchorDate: parseReminderAnchor("2026-10-04", TZ), + notifyHour: 18, + lastSatisfiedAt: null, + createdAt, + }; + const firstDue = computeReminderNextDueAt(schedule, TZ, createdAt); + expect(firstDue).toEqual(new Date("2026-10-04T16:00:00.000Z")); + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: "Blood pressure", + measurementType: "BLOOD_PRESSURE_SYS", + ...schedule, + origin: "VORSORGE", + enabled: true, + nextDueAt: firstDue, + createdAt, + }, + }); + const dispatch = vi.fn(async () => OK); + const tick = (at: Date) => + runMeasurementReminderTick(prisma, at, { dispatch }); + const read = () => + prisma.measurementReminder.findUniqueOrThrow({ + where: { id: created.id }, + }); + + // Reminded at 18:00; at 20:40 it still reads overdue, not "in 2 days". + expect((await tick(SIX_PM)).dispatched).toBe(1); + let row = await read(); + expect(row.nextDueAt).toEqual(firstDue); + expect(row.lastNotifiedAt).toEqual(SIX_PM); + const evening = new Date(SIX_PM.getTime() + 160 * 60 * 1000); + expect( + isOverdueDue( + relativeDueKey(row.nextDueAt!.toISOString(), evening.getTime(), TZ), + ), + ).toBe(true); + const fullUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + const digest = await loadDailyDigest(fullUser, evening, { locale: "en" }); + const care = digest.worthALook.find((i) => i.kind === "preventive_care"); + expect(care?.body).toContain("Blood pressure"); + + // The next evening: held. Two days on: reminded again, still due. + expect( + (await tick(new Date(SIX_PM.getTime() + DAY))).skippedRepeatHeld, + ).toBe(1); + const twoDaysOn = new Date(SIX_PM.getTime() + 2 * DAY); + await tick(twoDaysOn); + await tick(new Date(twoDaysOn.getTime() + 15 * 60 * 1000)); + expect(dispatch).toHaveBeenCalledTimes(2); + row = await read(); + expect(row.nextDueAt).toEqual(firstDue); + expect(row.lastNotifiedAt).toEqual(twoDaysOn); + + // Measured the next morning: two days on from the reading. + const measuredAt = new Date("2026-11-11T07:30:00.000Z"); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + value: 128, + unit: "mmHg", + measuredAt, + source: "MANUAL", + }, + }); + const resolved = await tick(new Date(SIX_PM.getTime() + 3 * DAY)); + expect(resolved.autoResolved).toBe(1); + row = await read(); + expect(row.lastSatisfiedAt).toEqual(measuredAt); + expect(row.nextDueAt).toEqual(new Date("2026-11-13T17:00:00.000Z")); + expect(dispatch).toHaveBeenCalledTimes(2); + }); +}); + describe("migration 0355", () => { it("adds the nullable last_notified_at column", async () => { const rows = await getPrismaClient().$queryRawUnsafe< @@ -395,7 +498,7 @@ describe("migration 0355", () => { updatedAt: new Date("2026-09-26T07:01:00.000Z"), }, { - // A weekly check-up still rolls on under the new tick. + // A weekly cycle is outside this migration's repair. id: "weekly", data: { rrule: null, @@ -404,7 +507,7 @@ describe("migration 0355", () => { }, }, { - // A measurement reminder on a short cycle still rolls on. + // A measurement reminder on a short cycle: outside this repair too. id: "measurement-3-day", data: { measurementType: "WEIGHT", diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 257ab7234..130569510 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,106 @@ describe("turning the subscription on", () => { expect(statuses).toContain(409); }); + /** + * 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 }, + select: { id: true }, + }); + let pending: Promise | undefined; + await db().$transaction( + async (tx) => { + await write(tx, feed.id); + 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 + WHERE datname = current_database() AND wait_event_type = 'Lock'`; + if (n > 0) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error("the request 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(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 }, + }), + ).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 = { @@ -407,6 +509,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"); 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); 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/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", 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/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"); diff --git a/tests/integration/sync-changes-start-now.test.ts b/tests/integration/sync-changes-start-now.test.ts index f77ee7591..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,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 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! }); - 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 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");