mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
commit
a486c3c8a3
148 files changed
+4668
-347
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -116,6 +116,7 @@ const FEED_SELECT = {
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "%",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user