Merge remote-tracking branch 'origin/release/v1.47.2' into takeover/1370

# Conflicts:
#	CHANGELOG.md
#	src/lib/module-list-prefs.ts
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:04:51 +01:00
commit a486c3c8a3
148 files changed
+4668 -347

No files matched your search

+96
View File
@@ -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
+1
View File
@@ -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,
+173 -26
View File
@@ -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:<actorId>`, 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:
+1
View File
@@ -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 |
+13 -6
View File
@@ -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
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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}개",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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",
+43 -1
View File
@@ -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}",
@@ -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$;
@@ -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';
+22 -4
View File
@@ -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)
@@ -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,
+2 -2
View File
@@ -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,
+11 -3
View File
@@ -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,
},
@@ -149,6 +149,8 @@ const ALLOWED_SQL: Record<string, string> = {
"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.",
};
@@ -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);
});
+1 -1
View File
@@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"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",
@@ -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,
@@ -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(
+40 -8
View File
@@ -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.
+28 -4
View File
@@ -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 });
});
@@ -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
@@ -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 &&
@@ -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);
@@ -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",
});
}
+7 -1
View File
@@ -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
+39
View File
@@ -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 (
<HealthKitMetricPage
measurementType="BLOOD_KETONES"
insightMetric="BLOOD_KETONES"
chartKey="bloodKetones"
i18nPrefix="insights.bloodKetones"
color="var(--chart-3)"
unit={KETONE_UNIT}
yAxisUnit={KETONE_UNIT}
statFractionDigits={1}
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType="BLOOD_KETONES"
captureType="BLOOD_KETONES"
afterChart={<GkiCard />}
/>
);
}
@@ -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.
<StatRow
label={t("measurements.glucoseKetoneIndex")}
value={statSummary(t, "", {
latest: fmtDigits(report.glucoseKetoneIndex.latest, 1),
avg: fmtDigits(report.glucoseKetoneIndex.avg, 1),
min: fmtDigits(report.glucoseKetoneIndex.min, 1),
max: fmtDigits(report.glucoseKetoneIndex.max, 1),
})}
/>
) : null}
{unvalidated > 0 && (
<p
className="text-muted-foreground text-xs"
@@ -30,6 +30,7 @@ const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "MARGIN" as const,
};
describe("<CycleCalendarFeedCard>", () => {
@@ -58,6 +59,30 @@ describe("<CycleCalendarFeedCard>", () => {
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 band = render({
feed: { ...FEED, forecastStyle: "BAND" },
projectionState: "OK",
fertileAvailable: false,
});
expect(band).toContain("One entry covering every day it may fall on.");
expect(band).not.toContain("as separate entries");
});
it("explains a shorter feed in the muted tone", () => {
const html = render({
feed: FEED,
@@ -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 }) {
</NativeSelect>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-forecast">{t("cycle.feed.forecasts")}</Label>
<NativeSelect
id="cycle-feed-forecast"
value={feed.forecastStyle}
disabled={update.isPending}
aria-describedby="cycle-feed-forecast-hint"
onChange={(e) =>
save({ forecastStyle: e.target.value as FeedForecastStyle })
}
>
{FORECAST_STYLES.map((s) => (
<option key={s} value={s}>
{t(`cycle.feed.forecastStyle.${s}`)}
</option>
))}
</NativeSelect>
<p
id="cycle-feed-forecast-hint"
className="text-muted-foreground text-sm"
data-slot="cycle-feed-forecast-hint"
>
{t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)}
</p>
</div>
{status.fertileAvailable ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
+7 -1
View File
@@ -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"
>
>;
+48
View File
@@ -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<DayResponse["glucoseKetoneIndex"]>[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 (
<li data-slot="day-gki" className="flex min-w-0">
<Link
href="/insights/ketones"
data-slot="day-value-link"
className="bg-muted/60 hover:bg-muted focus-visible:ring-ring/50 flex min-h-11 w-full min-w-0 flex-col rounded-lg px-3 py-2.5 transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="text-muted-foreground truncate text-xs">
{t("measurements.glucoseKetoneIndex")}
</span>
<span className="truncate text-base font-semibold tabular-nums">
{fmt.number(latest.value, 1)}
</span>
<span className="text-muted-foreground truncate text-xs tabular-nums">
{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,
})}
</span>
</Link>
</li>
);
}
+7 -3
View File
@@ -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<DayResponse, "values" | "glucoseKetoneIndex">;
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)}
/>
))}
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
</ul>
{rest.length > 0 ? (
<button
+1 -1
View File
@@ -296,7 +296,7 @@ export function DayView({
<DayRunning items={data.running} date={date} />
{data.values.length > 0 ? (
<div className="space-y-2.5">
<DayValues values={data.values} focusTypes={focusTypes} />
<DayValues day={data} focusTypes={focusTypes} />
<DayNotableLines
notables={otherNotables}
labelFor={labelFor}
@@ -380,6 +380,7 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
ketones: { labelKey: "insights.navBloodKetones", metric: "BLOOD_KETONES" },
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
@@ -0,0 +1,109 @@
"use client";
import dynamic from "next/dynamic";
import { Divide } from "lucide-react";
import {
ChartErrorBoundary,
ChartErrorState,
} from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
import { useGlucoseKetoneIndexSeries } from "@/lib/queries/use-glucose-ketone-index";
import { importWithRetry } from "@/lib/retry-import";
// The line chart the custom metrics draw: a value over time with range tabs
// and the day doors. Loaded through the one recharts boundary.
const IndexChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.CustomMetricChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The glucose-ketone index (#1367), beneath the ketone chart. Every point is
* one ketone reading and the glucose reading the server paired with it; the
* value is the server's and is drawn as given. The latest pair is spelled out
* so the number can be traced to the two readings it came from. No band and
* no verdict: what the index should be is the treating clinician's call.
*/
export function GkiCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const query = useGlucoseKetoneIndexSeries();
const points = query.data?.points ?? [];
const latest = points[points.length - 1];
const title = t("measurements.glucoseKetoneIndex");
return (
<Card data-slot="gki-card" aria-busy={query.isFetching || undefined}>
<CardHeader>
<TileHeader icon={Divide} title={title} titleAs="h2" />
<CardDescription>
{t("insights.bloodKetones.gki.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isPending ? (
<ChartSkeleton />
) : query.isError ? (
<ChartErrorState
error={query.error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={title}
onAction={() => void query.refetch()}
/>
) : !latest ? (
<EmptyState
variant="plain"
size="compact"
title={t("insights.bloodKetones.gki.emptyTitle")}
description={t("insights.bloodKetones.gki.emptyDescription")}
/>
) : (
<>
<p className="text-sm" data-slot="gki-latest">
{t("insights.bloodKetones.gki.latest", {
value: fmt.number(latest.value, 1),
glucose: fmt.number(latest.gki.glucoseMmol, 1),
ketones: fmt.number(latest.gki.ketonesMmol, 1),
unit: GKI_COMPONENT_UNIT,
date: fmt.dateTime(latest.at),
})}
</p>
<ChartErrorBoundary>
<IndexChartLazy
name={title}
unit=""
targetLow={null}
targetHigh={null}
decimals={1}
entries={points.map((p) => ({
id: p.id,
customMetricId: "",
value: p.value,
unit: "",
measuredAt: p.at,
note: null,
createdAt: p.at,
}))}
/>
</ChartErrorBoundary>
</>
)}
</CardContent>
</Card>
);
}
@@ -179,6 +179,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
},
},
});
@@ -226,6 +227,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
},
},
},
@@ -322,6 +324,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
},
},
},
@@ -378,6 +381,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
illness: {
@@ -386,6 +390,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
vorsorge: {
@@ -394,6 +399,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
},
@@ -424,6 +430,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "manual",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: true,
},
illness: {
@@ -432,6 +439,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
vorsorge: {
@@ -440,6 +448,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
},
@@ -496,6 +505,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "expanded",
categoryOrder: [],
},
},
});
@@ -533,6 +543,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "flagged",
categoryOrder: [],
},
},
});
@@ -48,6 +48,7 @@ const mockModulePrefs: ModuleListPrefs = {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
vi.mock("@/lib/module-list-prefs", async (importOriginal) => {
@@ -21,6 +21,7 @@ let mockModulePrefs: ModuleListPrefs = {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
const displayZone = vi.hoisted(() => ({ current: "UTC" }));
@@ -85,6 +86,7 @@ describe("<LabList>", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
});
@@ -184,6 +186,7 @@ describe("<LabList>", () => {
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "expanded",
categoryOrder: [],
};
const queryClient = new QueryClient({
@@ -257,6 +260,7 @@ describe("<LabList>", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
const queryClient = new QueryClient({
@@ -28,6 +28,21 @@ function render(prefs: Partial<ModuleListPrefs>) {
biomarkers: [],
customPanels: [],
});
// The server's answer, already in the person's order.
queryClient.setQueryData(queryKeys.labResultsOverview("en"), {
markers: [],
grouped: true,
categories: [
{ key: "thyroid", label: "Thyroid" },
{ key: "other", label: "Other" },
{ key: "lipids", label: "Lipids" },
].map((category) => ({
...category,
markerKeys: [],
counts: { inRange: 0, below: 0, above: 0, unknown: 0 },
status: "unknown",
})),
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -60,4 +75,26 @@ describe("<LabsSettings> categories", () => {
"The order applies within each category.",
);
});
it("offers the category order only while categories are on", () => {
expect(render({})).not.toContain('id="labs-category-order"');
expect(render({ groupBy: "category" })).toContain(
'id="labs-category-order"',
);
});
it("lists the categories as the server ordered them, under their labels", () => {
const html = render({ groupBy: "category" });
const card = html.slice(html.indexOf('id="labs-category-order"'));
const at = (label: string) => card.indexOf(`title="${label}"`);
expect(at("Thyroid")).toBeGreaterThan(-1);
expect(at("Thyroid")).toBeLessThan(at("Other"));
expect(at("Other")).toBeLessThan(at("Lipids"));
});
it("says how the categories are ordered until the person moves one", () => {
expect(render({ groupBy: "category" })).toContain(
"Until you move one, built-in categories keep a fixed order, your own follow alphabetically and Other comes last.",
);
});
});
+77 -5
View File
@@ -5,8 +5,8 @@
*
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers, and whether the list shows its categories and
* which of them start open. Persisted per account through
* order of the biomarkers, and whether the list shows its categories, which
* of them start open and their order. Persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
@@ -19,6 +19,7 @@ import {
ArrowDownUp,
FlaskConical,
FolderTree,
Layers,
LayoutGrid,
ListOrdered,
ScanText,
@@ -27,6 +28,7 @@ import {
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Segmented } from "@/components/ui/segmented";
@@ -47,16 +49,28 @@ import {
type ModuleGroupsOpen,
type ModuleSortDir,
} from "@/lib/module-list-prefs";
import { mergeCategoryOrder } from "@/lib/settings/module-list-layout";
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabOverviewResponse,
} from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } =
useModuleListPrefs("labs");
const {
prefs,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
setCategoryOrder,
} = useModuleListPrefs("labs");
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -120,6 +134,14 @@ export function LabsSettings() {
onGroupsOpen={setGroupsOpen}
/>
{prefs.groupBy === "category" ? (
<LabsCategoryOrderCard
onChange={(shown) =>
setCategoryOrder(mergeCategoryOrder(shown, prefs.categoryOrder))
}
/>
) : null}
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
@@ -204,6 +226,56 @@ function LabsGroupingCard({
);
}
/**
* The order of the categories. The server answers them already in the
* person's order (`GET /api/labs/overview` applies `categoryOrder`), so the
* editor lists them as given, under the labels the overview carries, and
* sends the whole order after every move.
*/
function LabsCategoryOrderCard({
onChange,
}: {
onChange: (categoryOrder: string[]) => void;
}) {
const { t, locale } = useTranslations();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.labResultsOverview(locale),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const items: ReorderItem[] = (data?.categories ?? []).map((category) => ({
id: category.key,
name: category.label,
}));
return (
<SettingsCard id="labs-category-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Layers}
title={t("labs.groups.orderHeading")}
description={t("labs.groups.orderDefault", {
other: t("labs.groups.other"),
})}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
<QueryErrorRow
error={error}
inset
slot="labs-category-order-error"
onRetry={() => void refetch()}
/>
) : (
<ModuleOrderEditor items={items} onChange={onChange} />
)}
</SettingsCard>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { ketoneFingerstickBatch } from "@/components/measurements/ketone-fingerstick";
import { createBatchMeasurementSchema } from "@/lib/validations/measurement";
const AT = "2026-11-01T08:00:00.000Z";
function batch(
overrides: Partial<Parameters<typeof ketoneFingerstickBatch>[0]>,
) {
return ketoneFingerstickBatch({
ketones: "1.2",
glucose: "",
glucoseUnit: "mg/dL",
glucoseContext: "FASTING",
measuredAt: AT,
notes: "",
...overrides,
});
}
describe("ketoneFingerstickBatch", () => {
it("posts the ketones alone when the meter gave no glucose", () => {
expect(batch({})).toEqual([
{ type: "BLOOD_KETONES", value: 1.2, measuredAt: AT, notes: undefined },
]);
});
it("posts the glucose and the ketones with one timestamp", () => {
const rows = batch({
glucose: "81",
ketones: "1,5",
notes: "after breakfast",
});
expect(rows).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 81,
measuredAt: AT,
notes: "after breakfast",
glucoseContext: "FASTING",
},
{
type: "BLOOD_KETONES",
value: 1.5,
measuredAt: AT,
notes: "after breakfast",
},
]);
// The batch the route validates: the shape the form sends is accepted.
expect(
createBatchMeasurementSchema.safeParse({ measurements: rows }).success,
).toBe(true);
});
it("stores glucose typed in mmol/L as mg/dL", () => {
const rows = batch({ glucose: "4.5", glucoseUnit: "mmol/L" });
expect(rows?.[0]).toMatchObject({ type: "BLOOD_GLUCOSE", value: 81 });
});
it("refuses a number it cannot read rather than dropping half the pair", () => {
expect(batch({ ketones: "" })).toBeNull();
expect(batch({ ketones: "abc" })).toBeNull();
expect(batch({ glucose: "lots" })).toBeNull();
});
it("is refused by the route's schema above the meters' range", () => {
const rows = batch({ ketones: "12", glucose: "90" });
expect(
createBatchMeasurementSchema.safeParse({ measurements: rows }).success,
).toBe(false);
});
});
@@ -0,0 +1,56 @@
/**
* One fingerstick as one entry (#1367): the ketones, and the glucose from the
* same drop when the meter gave one, as the batch the measurements route
* writes in one transaction. Both readings carry the one timestamp, so the
* server pairs them exactly when it derives the glucose-ketone index.
*
* Pure, so the form's write path can be pinned without rendering it.
*/
import { toCanonicalMgdl, type GlucoseUnit } from "@/lib/glucose";
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
export interface FingerstickRow {
type: "BLOOD_GLUCOSE" | "BLOOD_KETONES";
value: number;
measuredAt: string;
notes?: string;
glucoseContext?: string;
}
/**
* The rows to post, glucose first, or null when a typed number does not read
* as one. An empty glucose field means the meter gave ketones only.
*/
export function ketoneFingerstickBatch(input: {
ketones: string;
glucose: string;
/** The unit the glucose was typed in: the account's glucose unit. */
glucoseUnit: GlucoseUnit;
glucoseContext: string;
measuredAt: string;
notes: string;
}): FingerstickRow[] | null {
const ketones = parseDecimalEntry(input.ketones);
if (ketones === null) return null;
const notes = input.notes || undefined;
const ketoneRow: FingerstickRow = {
type: "BLOOD_KETONES",
value: ketones,
measuredAt: input.measuredAt,
notes,
};
if (input.glucose.trim() === "") return [ketoneRow];
const glucose = parseDecimalEntry(input.glucose);
if (glucose === null) return null;
return [
{
type: "BLOOD_GLUCOSE",
// Stored in mg/dL whatever the reader types in.
value: toCanonicalMgdl(glucose, input.glucoseUnit),
measuredAt: input.measuredAt,
notes,
glucoseContext: input.glucoseContext,
},
ketoneRow,
];
}
@@ -0,0 +1,127 @@
"use client";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { glucoseFractionDigits, type GlucoseUnit } from "@/lib/glucose";
import { useTranslations } from "@/lib/i18n/context";
/**
* The measurement form's action row: the reset menu, Cancel and Save. The
* Save button names the form by id, so it still submits it when the row is
* portalled into a sheet's footer slot.
*/
export function MeasurementFormFooter({
loading,
formId,
onReset,
onCancel,
}: {
loading: boolean;
formId: string;
onReset: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslations();
return (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={loading}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={onReset}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("measurements.formReset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
/**
* #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 (
<FieldGroup
htmlFor="paired-glucose"
label={
<>
{t("measurements.pairedGlucoseLabel", { unit })}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
}
hint={t("measurements.pairedGlucoseHint")}
>
<Input
id="paired-glucose"
// Not `type="number"`, like the value field: a decimal comma must
// survive on every engine.
inputMode="decimal"
enterKeyHint="next"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={glucoseFractionDigits(unit) > 0 ? "5.3" : "95"}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
/>
</FieldGroup>
);
}
@@ -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 = (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={loading}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("measurements.formReset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
<MeasurementFormFooter
loading={loading}
formId={formId}
onReset={resetForm}
onCancel={onCancel}
/>
);
return (
@@ -718,7 +705,17 @@ export function MeasurementForm({
</FieldGroup>
)}
{isGlucoseMode && (
{isKetoneMode && (
<PairedGlucoseField
value={pairedGlucose}
onChange={setPairedGlucose}
unit={glucoseUnit}
invalid={!!error}
describedBy={errorDescriptor}
/>
)}
{(isGlucoseMode || (isKetoneMode && pairedGlucose.trim() !== "")) && (
<FieldGroup
htmlFor="glucose-context"
label={t("measurements.glucoseContext")}
@@ -48,6 +48,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
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<string, LucideIcon> = {
SLEEP_DURATION: Moon,
ACTIVITY_STEPS: Footprints,
BLOOD_GLUCOSE: Droplet,
BLOOD_KETONES: Droplet,
TOTAL_BODY_WATER: Droplet,
BONE_MASS: Bone,
OXYGEN_SATURATION: Wind,
@@ -13,6 +13,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
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",
@@ -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(),
);
@@ -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<typeof overview>).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;
+145
View File
@@ -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");
});
});
+1
View File
@@ -48,6 +48,7 @@ export const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "BAND" as const,
};
export const SUBJECT = {
@@ -54,6 +54,7 @@ const FEED_ROW = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET",
forecastStyle: "BAND",
};
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
+7 -1
View File
@@ -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,
+85 -19
View File
@@ -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 {
+1
View File
@@ -116,6 +116,7 @@ const FEED_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
/**
+5 -1
View File
@@ -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,
};
}
+4 -4
View File
@@ -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,
+7 -7
View File
@@ -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<PriorityItem>(
[
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),
);
+12 -12
View File
@@ -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
+1
View File
@@ -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];
+15
View File
@@ -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<DayResponse> {
]);
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<DayResponse> {
counts: { values: values.length, entries: events.length },
running,
values,
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
events,
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
scores,
+24
View File
@@ -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
@@ -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;
}
+7
View File
@@ -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<string, DoctorReportCompliance>;
+4 -3
View File
@@ -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 : {},
@@ -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<Record<string, DoctorReportStats>>,
userId: string,
/** The report window; `end` is inclusive, as the measurement read has it. */
start: Date,
end: Date,
): Promise<DoctorReportStats | null> {
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,
};
}
@@ -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.
+21 -3
View File
@@ -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,
+26
View File
@@ -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(
{
+1
View File
@@ -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",
+2
View File
@@ -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
+3
View File
@@ -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<Record<SubPageSlug, SubPageGroup>> = {
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
@@ -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<typeof makePrisma>["prisma"],
now: Date,
options: Parameters<typeof runMeasurementReminderTick>[2] = {},
) {
return runMeasurementReminderTick(prisma as never, now, options);
}
function reminder(overrides: Partial<FakeReminder>): 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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<FakeReminder> = {}) =>
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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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);
});
});
+49 -29
View File
@@ -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,
@@ -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",
]);
});
});
+21 -1
View File
@@ -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");
});
});
+36 -3
View File
@@ -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<T extends { key: string }>(
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 };
}
+23 -1
View File
@@ -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<string, string> = 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);
@@ -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);
});
});
@@ -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,
+48 -19
View File
@@ -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) {
@@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// 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", () => {
@@ -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,
});
});
});
+1
View File
@@ -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.
@@ -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]!,
};
}
@@ -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<GlucoseKetoneIndexPoint[]> {
const { userId, from, to } = args;
const ketones = await prisma.$queryRaw<Row[]>`
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<Row[]>`
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));
}
@@ -44,7 +44,9 @@ export type SeriesKind =
| "oxygenSaturation"
| "restingHeartRate"
| "heartRateVariability"
| "vo2Max";
| "vo2Max"
| "ketones"
| "glucoseKetoneIndex";
export interface SeriesReferenceLine {
value: number;
+23
View File
@@ -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<typeof kindEnum>;
@@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record<SeriesKind, MeasurementType> = {
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<SeriesKind, string> = {
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 {
@@ -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<SeriesRead> {
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,
},
})),
};
}
@@ -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<SeriesRead> {
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);
}
+1
View File
@@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
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",
+1
View File
@@ -21,6 +21,7 @@ const unitMap: Record<string, string> = {
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
BLOOD_KETONES: "mmol/L",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
+6
View File
@@ -20,6 +20,12 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
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
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more