Merge pull request #1365 from MBombeck/release/v1.47.2

release: v1.47.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-11-11 07:21:17 +01:00
commit aae029d94e
182 files changed
+8355 -610

No files matched your search

+132
View File
@@ -2,6 +2,138 @@
## [Unreleased]
## [1.47.2] — 2026-11-11
### Added
- **Search and filter the labs list.** Once the list ran past a
screenful, finding one marker meant scrolling. The labs page now has a
search field (press / to jump to it) that matches a marker's name or
panel, and filters for the newest reading's status (out of range, in
range, below or above), the category, and when a marker was last
measured. While a filter is active every category with a match is
open, and its header counts only the matches. The filters are part of
the page address, so a filtered view survives a reload and
back/forward and can be shared as a link. The list keeps the sort
order set under Settings > Arrangement > Labs. (#1370)
- **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 it is
reminded again every N days, and never more than a week apart, 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 already saved in just "(calc)"
keeps that unit. Any other text in brackets still counts as part of
the unit. (#1313)
- **The sync feed no longer loses a reading written during a long
import, 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.
- **A seven-day blood pressure course now ends after seven days.** A
course suggested by the Coach knew its last day only until its first
reminder went out. From then on every sent reminder, every reading and
every skip planned the next one without that end, so the morning and
evening reminders went on for good. The end now holds through every
reschedule, and once the last slot is past the course is cleared from
the list as intended. A course that already ran past its end sends the
one reminder it has planned and then stops.
### Security
- `node-forge` moves to 1.4.3, which fixes five advisories in certificate
chain checks, signatures and TLS handling. It arrives only under the APNs
library, and HealthLog signs push requests with a token key, so none of
that code runs; the update keeps the dependency audit clean.
- The image carries Debian's zlib security update (DSA-6613-1, zlib1g
`1:1.3.dfsg+really1.3.1-1+deb13u1`), which fixes CVE-2026-85091,
CVE-2026-27171 and CVE-2026-76844. The 1.47.1 image was built before
that update and ships the earlier zlib. The refreshed `node:22-trixie-slim`
base does not include it yet either; the build-time `apt-get upgrade`
brings it in.
## [1.47.1] — 2026-11-07
### Added
+1
View File
@@ -58,6 +58,7 @@
"/insights/headphone-audio/page": 336,
"/insights/health-score/page": 315,
"/insights/hrv/page": 337,
"/insights/ketones/page": 339,
"/insights/lean-body-mass/page": 336,
"/insights/medications/page": 338,
"/insights/mood/page": 337,
+176 -28
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.47.1
version: 1.47.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -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
@@ -77175,7 +77275,8 @@ components:
spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status`
combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else
`in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category,
the order or the status."
the order or the status. A client that shows only the members matching a filter may summarise that subset by the
same rule; without a filter it shows `counts` and `status` as sent."
LabOverviewCategoryCounts:
type: object
properties:
@@ -88191,6 +88292,7 @@ components:
- scoreSleep
- scoreStress
- scoreStrain
- bloodKetones
additionalProperties:
type: object
properties:
@@ -88457,6 +88559,7 @@ components:
- scoreSleep
- scoreStress
- scoreStrain
- bloodKetones
additionalProperties:
type: object
properties:
@@ -93831,6 +93934,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 +94146,43 @@ components:
- source
- band
additionalProperties: false
DayGlucoseKetoneIndex:
type: object
properties:
at:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: The ketone reading's instant.
value:
type: number
description: The index, to one decimal.
ketoneId:
type: string
description: The ketone reading's id.
glucoseId:
type: string
description: The paired glucose reading's id.
glucoseAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: The paired glucose reading's instant.
ketonesMmol:
type: number
description: Beta-hydroxybutyrate, mmol/L.
glucoseMmol:
type: number
description: The paired glucose in mmol/L, one decimal.
required:
- at
- value
- ketoneId
- glucoseId
- glucoseAt
- ketonesMmol
- glucoseMmol
additionalProperties: false
DayEvent:
type: object
properties:
+1
View File
@@ -163,6 +163,7 @@ hand-authored.
| `RESPIRATORY_RATE` | count/min |
| `OXYGEN_SATURATION` | percent (0–100) |
| `BLOOD_GLUCOSE` | mg/dL |
| `BLOOD_KETONES` | mmol/L |
| `BODY_FAT` | percent (0–100) |
| `FAT_MASS` | kg |
| `FAT_FREE_MASS` | kg |
+13 -6
View File
@@ -32,16 +32,23 @@ managed on the web; the iOS app shows where to find it.
## What the feed shows
- The next period and, once three cycles are logged, a few after it (three
by default, up to six). Each event covers the whole window the period may
fall in, and the window widens the further out it is. The list stops itself
where a date would be more guess than estimate (beyond about ±10 days, or
half a cycle).
by default, up to six). The range a period may fall in widens the further
out it is, and the list stops itself where a date would be more guess
than estimate (beyond about ±10 days, or half a cycle). How that range is
drawn is up to you, under "How forecasts look":
- **Expected days with margin** (the default for a new subscription): the
expected days as one event, and the days the period may start earlier
or run on later as separate "Period possible" events.
- **Expected days**: only the expected days, as one event.
- **One range**: one event covering the whole range. A subscription
turned on before v1.47.2 keeps this style until you change it, and
renewing the link keeps whichever style it has.
- Optionally, your past cycles (up to twelve).
- Optionally, when your goal is trying to conceive, the estimated fertile
window of the next cycle. It is an estimate, not a contraceptive method.
Titles are discreet by default: each event is a dot and the calendar is called
"HealthLog". Descriptive titles name the period instead. Events never carry
Titles are discreet by default: a period is a dot (●), a possible day an open
dot (○), and the calendar is called "HealthLog". Descriptive titles name the period instead. Events never carry
symptoms, notes or any other detail of your log.
When a period is later than its window, the feed shows no forecast until you
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Schlaf",
"typeSteps": "Schritte",
"typeBloodGlucose": "Blutzucker",
"typeBloodKetones": "Blutketone",
"glucoseKetoneIndex": "Glukose-Keton-Index",
"pairedGlucoseLabel": "Blutzucker aus derselben Messung ({unit})",
"pairedGlucoseHint": "Wird als eigener Blutzuckerwert mit derselben Uhrzeit gespeichert; aus dem Paar ergibt sich der Glukose-Keton-Index.",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glukose",
"navBloodKetones": "Ketone",
"bloodKetones": {
"title": "Blutketone",
"description": "Deine Beta-Hydroxybutyrat-Werte im Zeitverlauf, in {unit}.",
"chartTitle": "Blutketone",
"emptyState": {
"title": "Noch keine Ketonwerte",
"description": "Ketonwerte erscheinen hier, sobald du einen erfasst, allein oder mit dem Blutzucker aus demselben Fingerstich.",
"cta": "Ketone erfassen"
},
"gki": {
"description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.",
"emptyTitle": "Noch kein Index",
"emptyDescription": "Er erscheint, sobald zu einem Ketonwert über null ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.",
"latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Gesamtkörperwasser",
"description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Diskret (●)",
"DESCRIPTIVE": "Beschreibend"
},
"forecasts": "Darstellung der Schätzung",
"forecastStyle": {
"EXACT": "Nur erwartete Tage",
"MARGIN": "Erwartete Tage mit Spielraum",
"BAND": "Ein Zeitraum"
},
"forecastStyleHint": {
"EXACT": "Nur die erwarteten Tage.",
"MARGIN": "Die erwarteten Tage, dazu als eigene Termine die Tage davor und danach, falls die Periode früher oder später beginnt.",
"BAND": "Ein Termin über alle Tage, auf die die Periode fallen kann."
},
"fertile": "Fruchtbares Fenster",
"fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.",
"rotate": "Link erneuern",
@@ -10075,6 +10107,7 @@
"periodEstimate": "Periode (Schätzung)",
"periodLogged": "Periode (erfasst)",
"fertileEstimate": "Fruchtbares Fenster (Schätzung)",
"periodPossible": "Periode möglich",
"likelyStart": "Wahrscheinlicher Beginn {date}, {band}",
"bandOne": "±{count} Tag",
"bandFew": "±{count} Tage",
@@ -10584,7 +10617,9 @@
"openExpanded": "Alle",
"openFlagged": "Nur außerhalb des Bereichs",
"openCollapsed": "Keine",
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie."
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie.",
"orderHeading": "Reihenfolge der Kategorien",
"orderDefault": "Bis du eine verschiebst, stehen eingebaute Kategorien in fester Folge, deine eigenen alphabetisch und {other} zuletzt."
},
"sourceRange": {
"fromReport": "Befundbereich {range}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Keine Einheit",
"noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL."
},
"filter": {
"searchPlaceholder": "Biomarker suchen…",
"searchLabel": "Biomarker suchen",
"clearSearch": "Suche leeren",
"lastMeasured": "Zuletzt gemessen",
"statusLabel": "Status",
"statusAll": "Alle Status",
"statusAbnormal": "Außerhalb des Bereichs",
"categoryLabel": "Kategorie",
"categoryAll": "Alle Kategorien",
"markerCountOne": "{count} Biomarker",
"markerCountFew": "{count} Biomarker",
"markerCountOther": "{count} Biomarker",
"noMatchesTitle": "Keine passenden Biomarker",
"noMatchesDescription": "Passe deinen Suchbegriff oder die Filter an."
}
},
"modules": {
@@ -11497,6 +11548,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": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Alle {count} Werte",
"valuesAllOther": "Alle {count} Werte",
"valuesFewer": "Weniger Werte",
"gkiPair": "Blutzucker {glucose} {unit} / Ketone {ketones} {unit}",
"gkiPairShort": "{glucose} / {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",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Sleep",
"typeSteps": "Steps",
"typeBloodGlucose": "Blood glucose",
"typeBloodKetones": "Blood ketones",
"glucoseKetoneIndex": "Glucose-ketone index",
"pairedGlucoseLabel": "Glucose from the same reading ({unit})",
"pairedGlucoseHint": "Saved as its own glucose reading at the same time; the glucose-ketone index is worked out from the pair.",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glucose",
"navBloodKetones": "Ketones",
"bloodKetones": {
"title": "Blood Ketones",
"description": "Your beta-hydroxybutyrate readings over time, in {unit}.",
"chartTitle": "Blood ketones",
"emptyState": {
"title": "No ketone readings yet",
"description": "Ketone readings appear here once you log one, on its own or with the glucose from the same fingerstick.",
"cta": "Log ketones"
},
"gki": {
"description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.",
"emptyTitle": "No index yet",
"emptyDescription": "It appears once a ketone reading above zero has a glucose reading taken at the same time or within 15 minutes.",
"latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Total Body Water",
"description": "Your total body water over time, in kilograms.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Discreet (●)",
"DESCRIPTIVE": "Descriptive"
},
"forecasts": "How forecasts look",
"forecastStyle": {
"EXACT": "Expected days",
"MARGIN": "Expected days with margin",
"BAND": "One range"
},
"forecastStyleHint": {
"EXACT": "Just the expected days.",
"MARGIN": "Expected days, with the days the period may start earlier or later as separate entries.",
"BAND": "One entry covering every day the period 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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "No unit",
"noneHint": "For a ratio or an index such as INR or cholesterol/HDL."
},
"filter": {
"searchPlaceholder": "Search biomarkers…",
"searchLabel": "Search biomarkers",
"clearSearch": "Clear search",
"lastMeasured": "Last measured",
"statusLabel": "Status",
"statusAll": "All statuses",
"statusAbnormal": "Out of range",
"categoryLabel": "Category",
"categoryAll": "All categories",
"markerCountOne": "{count} biomarker",
"markerCountFew": "{count} biomarkers",
"markerCountOther": "{count} biomarkers",
"noMatchesTitle": "No matching biomarkers",
"noMatchesDescription": "Try adjusting your search query or filters."
}
},
"modules": {
@@ -11497,6 +11548,9 @@
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgium, 24/7, free, anonymous)"
},
"suicidePrevention109": {
"name": "Suicide prevention line 109 (South Korea, 24/7, free)"
}
},
"instrumentDescription": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "All {count} values",
"valuesAllOther": "All {count} values",
"valuesFewer": "Fewer values",
"gkiPair": "Glucose {glucose} {unit} / ketones {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Latest of {count}",
"gkiCountFew": "Latest of {count}",
"gkiCountOther": "Latest of {count}",
"countEntriesOne": "1 entry",
"countEntriesFew": "{count} entries",
"countEntriesOther": "{count} entries",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Sueño",
"typeSteps": "Pasos",
"typeBloodGlucose": "Glucemia",
"typeBloodKetones": "Cetonas en sangre",
"glucoseKetoneIndex": "Índice glucosa-cetonas",
"pairedGlucoseLabel": "Glucosa de la misma medición ({unit})",
"pairedGlucoseHint": "Se guarda como una lectura de glucosa propia con la misma hora; el índice glucosa-cetonas se calcula a partir del par.",
"typeTotalBodyWater": "Agua corporal total",
"typeBoneMass": "Masa ósea",
"typeOxygenSaturation": "Saturación de oxígeno",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glucosa",
"navBloodKetones": "Cetonas",
"bloodKetones": {
"title": "Cetonas en sangre",
"description": "Tus lecturas de beta-hidroxibutirato en el tiempo, en {unit}.",
"chartTitle": "Cetonas en sangre",
"emptyState": {
"title": "Aún no hay lecturas de cetonas",
"description": "Las lecturas de cetonas aparecen aquí cuando registras una, sola o con la glucosa del mismo pinchazo.",
"cta": "Registrar cetonas"
},
"gki": {
"description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.",
"emptyTitle": "Aún no hay índice",
"emptyDescription": "Aparece cuando una lectura de cetonas mayor que cero tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.",
"latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Agua corporal total",
"description": "Tu agua corporal total en el tiempo, en kilogramos.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descriptivo"
},
"forecasts": "Cómo se ven las previsiones",
"forecastStyle": {
"EXACT": "Días previstos",
"MARGIN": "Días previstos y margen",
"BAND": "Un solo intervalo"
},
"forecastStyleHint": {
"EXACT": "Solo los días previstos.",
"MARGIN": "Los días previstos, con los días en que el periodo puede empezar antes o después como eventos aparte.",
"BAND": "Un solo evento que cubre todos los días en que puede caer el periodo."
},
"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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Sin unidad",
"noneHint": "Para un cociente o un índice como el INR o colesterol/HDL."
},
"filter": {
"searchPlaceholder": "Buscar biomarcadores…",
"searchLabel": "Buscar biomarcadores",
"clearSearch": "Borrar búsqueda",
"lastMeasured": "Última medición",
"statusLabel": "Estado",
"statusAll": "Todos los estados",
"statusAbnormal": "Fuera de rango",
"categoryLabel": "Categoría",
"categoryAll": "Todas las categorías",
"markerCountOne": "{count} biomarcador",
"markerCountFew": "{count} biomarcadores",
"markerCountOther": "{count} biomarcadores",
"noMatchesTitle": "No se encontraron biomarcadores",
"noMatchesDescription": "Intenta cambiar los términos de búsqueda o los filtros."
}
},
"modules": {
@@ -11497,6 +11548,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": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Los {count} valores",
"valuesAllOther": "Los {count} valores",
"valuesFewer": "Menos valores",
"gkiPair": "Glucosa {glucose} {unit} / cetonas {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Último de {count}",
"gkiCountFew": "Último de {count}",
"gkiCountOther": "Último de {count}",
"countEntriesOne": "1 entrada",
"countEntriesFew": "{count} entradas",
"countEntriesOther": "{count} entradas",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Sommeil",
"typeSteps": "Pas",
"typeBloodGlucose": "Glycémie",
"typeBloodKetones": "Cétones sanguines",
"glucoseKetoneIndex": "Indice glucose-cétones",
"pairedGlucoseLabel": "Glycémie de la même mesure ({unit})",
"pairedGlucoseHint": "Enregistrée comme un relevé de glycémie à la même heure ; l’indice glucose-cétones est calculé à partir de la paire.",
"typeTotalBodyWater": "Eau corporelle totale",
"typeBoneMass": "Masse osseuse",
"typeOxygenSaturation": "Saturation en oxygène",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glucose",
"navBloodKetones": "Cétones",
"bloodKetones": {
"title": "Cétones sanguines",
"description": "Vos relevés de bêta-hydroxybutyrate au fil du temps, en {unit}.",
"chartTitle": "Cétones sanguines",
"emptyState": {
"title": "Pas encore de relevé de cétones",
"description": "Les relevés de cétones apparaissent ici dès que vous en saisissez un, seul ou avec la glycémie de la même piqûre.",
"cta": "Enregistrer les cétones"
},
"gki": {
"description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.",
"emptyTitle": "Pas encore d’indice",
"emptyDescription": "Il apparaît dès qu’un relevé de cétones supérieur à zéro a une glycémie prise au même moment ou à 15 minutes près.",
"latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Eau corporelle totale",
"description": "Votre eau corporelle totale au fil du temps, en kilogrammes.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Discret (●)",
"DESCRIPTIVE": "Explicite"
},
"forecasts": "Affichage des prévisions",
"forecastStyle": {
"EXACT": "Jours prévus",
"MARGIN": "Jours prévus et marge",
"BAND": "Une seule plage"
},
"forecastStyleHint": {
"EXACT": "Seulement les jours prévus.",
"MARGIN": "Les jours prévus, avec en événements séparés les jours où les règles peuvent commencer plus tôt ou plus tard.",
"BAND": "Un seul événement couvrant tous les jours où les règles 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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Sans unité",
"noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL."
},
"filter": {
"searchPlaceholder": "Rechercher des biomarqueurs…",
"searchLabel": "Rechercher des biomarqueurs",
"clearSearch": "Effacer la recherche",
"lastMeasured": "Dernière mesure",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"statusAbnormal": "Hors normes",
"categoryLabel": "Catégorie",
"categoryAll": "Toutes les catégories",
"markerCountOne": "{count} biomarqueur",
"markerCountFew": "{count} biomarqueurs",
"markerCountOther": "{count} biomarqueurs",
"noMatchesTitle": "Aucun biomarqueur trouvé",
"noMatchesDescription": "Essayez d'ajuster votre recherche ou vos filtres."
}
},
"modules": {
@@ -11497,6 +11548,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": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Les {count} valeurs",
"valuesAllOther": "Les {count} valeurs",
"valuesFewer": "Moins de valeurs",
"gkiPair": "Glycémie {glucose} {unit} / cétones {ketones} {unit}",
"gkiPairShort": "{glucose} / {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",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Sonno",
"typeSteps": "Passi",
"typeBloodGlucose": "Glicemia",
"typeBloodKetones": "Chetoni nel sangue",
"glucoseKetoneIndex": "Indice glucosio-chetoni",
"pairedGlucoseLabel": "Glicemia della stessa misurazione ({unit})",
"pairedGlucoseHint": "Salvata come lettura di glicemia a sé con lo stesso orario; l’indice glucosio-chetoni si ricava dalla coppia.",
"typeTotalBodyWater": "Acqua corporea totale",
"typeBoneMass": "Massa ossea",
"typeOxygenSaturation": "Saturazione di ossigeno",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glucosio",
"navBloodKetones": "Chetoni",
"bloodKetones": {
"title": "Chetoni nel sangue",
"description": "Le tue letture di beta-idrossibutirrato nel tempo, in {unit}.",
"chartTitle": "Chetoni nel sangue",
"emptyState": {
"title": "Ancora nessuna lettura di chetoni",
"description": "Le letture di chetoni appaiono qui quando ne registri una, da sola o con la glicemia della stessa puntura.",
"cta": "Registra i chetoni"
},
"gki": {
"description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.",
"emptyTitle": "Ancora nessun indice",
"emptyDescription": "Compare quando una lettura di chetoni superiore a zero ha una glicemia presa allo stesso momento o entro 15 minuti.",
"latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Acqua corporea totale",
"description": "La tua acqua corporea totale nel tempo, in chilogrammi.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descrittivo"
},
"forecasts": "Aspetto delle previsioni",
"forecastStyle": {
"EXACT": "Giorni previsti",
"MARGIN": "Giorni previsti e margine",
"BAND": "Un unico intervallo"
},
"forecastStyleHint": {
"EXACT": "Solo i giorni previsti.",
"MARGIN": "I giorni previsti, con i giorni in cui le mestruazioni possono iniziare prima o dopo come eventi separati.",
"BAND": "Un unico evento che copre tutti i giorni in cui possono cadere le mestruazioni."
},
"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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Nessuna unità",
"noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL."
},
"filter": {
"searchPlaceholder": "Cerca biomarcatori…",
"searchLabel": "Cerca biomarcatori",
"clearSearch": "Cancella ricerca",
"lastMeasured": "Ultima misurazione",
"statusLabel": "Stato",
"statusAll": "Tutti gli stati",
"statusAbnormal": "Fuori intervallo",
"categoryLabel": "Categoria",
"categoryAll": "Tutte le categorie",
"markerCountOne": "{count} biomarcatore",
"markerCountFew": "{count} biomarcatori",
"markerCountOther": "{count} biomarcatori",
"noMatchesTitle": "Nessun biomarcatore trovato",
"noMatchesDescription": "Prova a modificare i termini di ricerca o i filtri."
}
},
"modules": {
@@ -11497,6 +11548,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": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Tutti i {count} valori",
"valuesAllOther": "Tutti i {count} valori",
"valuesFewer": "Meno valori",
"gkiPair": "Glicemia {glucose} {unit} / chetoni {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Ultimo di {count}",
"gkiCountFew": "Ultimo di {count}",
"gkiCountOther": "Ultimo di {count}",
"countEntriesOne": "1 voce",
"countEntriesFew": "{count} voci",
"countEntriesOther": "{count} voci",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "수면",
"typeSteps": "걸음 수",
"typeBloodGlucose": "혈당",
"typeBloodKetones": "혈중 케톤",
"glucoseKetoneIndex": "포도당-케톤 지수",
"pairedGlucoseLabel": "같은 측정의 혈당 ({unit})",
"pairedGlucoseHint": "같은 시각의 별도 혈당 기록으로 저장되고, 두 값으로 포도당-케톤 지수를 계산해요.",
"typeTotalBodyWater": "체수분",
"typeBoneMass": "골량",
"typeOxygenSaturation": "산소포화도",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "혈당",
"navBloodKetones": "케톤",
"bloodKetones": {
"title": "혈중 케톤",
"description": "시간에 따른 베타-하이드록시부티르산 측정값이에요({unit}).",
"chartTitle": "혈중 케톤",
"emptyState": {
"title": "아직 케톤 측정값이 없어요",
"description": "케톤을 단독으로 또는 같은 채혈의 혈당과 함께 기록하면 여기에 측정값이 나타나요.",
"cta": "케톤 기록"
},
"gki": {
"description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.",
"emptyTitle": "아직 지수가 없어요",
"emptyDescription": "0보다 큰 케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.",
"latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "총 체수분",
"description": "시간에 따른 총 체수분이에요(kg).",
@@ -10056,6 +10077,17 @@
"DISCREET": "드러나지 않게 (●)",
"DESCRIPTIVE": "내용 표시"
},
"forecasts": "예측 표시 방식",
"forecastStyle": {
"EXACT": "예상일만",
"MARGIN": "예상일과 여유 기간",
"BAND": "하나의 기간"
},
"forecastStyleHint": {
"EXACT": "예상되는 날만 보여줘요.",
"MARGIN": "예상일과 함께, 더 일찍 또는 늦게 시작할 수 있는 날을 별도 일정으로 보여줘요.",
"BAND": "생리가 있을 수 있는 모든 날을 하나의 일정으로 보여줘요."
},
"fertile": "가임기",
"fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.",
"rotate": "링크 다시 발급",
@@ -10075,6 +10107,7 @@
"periodEstimate": "생리 (예상)",
"periodLogged": "생리 (기록)",
"fertileEstimate": "가임기 (예상)",
"periodPossible": "생리 가능",
"likelyStart": "가장 가능성 높은 시작일 {date}, {band}",
"bandOne": "±{count}일",
"bandFew": "±{count}일",
@@ -10584,7 +10617,9 @@
"openExpanded": "모두",
"openFlagged": "범위 밖만",
"openCollapsed": "없음",
"orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요."
"orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요.",
"orderHeading": "카테고리 순서",
"orderDefault": "직접 옮기기 전에는 기본 카테고리가 정해진 순서로, 직접 만든 카테고리가 가나다순으로 이어지고 {other}가 마지막에 와요."
},
"sourceRange": {
"fromReport": "결과지 범위 {range}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "단위 없음",
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
},
"filter": {
"searchPlaceholder": "바이오마커 검색…",
"searchLabel": "바이오마커 검색",
"clearSearch": "검색어 지우기",
"lastMeasured": "마지막 측정",
"statusLabel": "상태",
"statusAll": "모든 상태",
"statusAbnormal": "범위 벗어남",
"categoryLabel": "카테고리",
"categoryAll": "모든 카테고리",
"markerCountOne": "바이오마커 {count}개",
"markerCountFew": "바이오마커 {count}개",
"markerCountOther": "바이오마커 {count}개",
"noMatchesTitle": "일치하는 바이오마커 없음",
"noMatchesDescription": "검색어 또는 필터를 조정해 보세요."
}
},
"modules": {
@@ -11497,6 +11548,9 @@
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (벨기에, 24시간, 무료, 익명)"
},
"suicidePrevention109": {
"name": "자살예방상담전화 109 (24시간, 무료)"
}
},
"instrumentDescription": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "수치 {count}개 모두",
"valuesAllOther": "수치 {count}개 모두",
"valuesFewer": "수치 줄여 보기",
"gkiPair": "혈당 {glucose} {unit} / 케톤 {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "{count}개 중 최근",
"gkiCountFew": "{count}개 중 최근",
"gkiCountOther": "{count}개 중 최근",
"countEntriesOne": "기록 1개",
"countEntriesFew": "기록 {count}개",
"countEntriesOther": "기록 {count}개",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Slaap",
"typeSteps": "Stappen",
"typeBloodGlucose": "Bloedglucose",
"typeBloodKetones": "Bloedketonen",
"glucoseKetoneIndex": "Glucose-ketonindex",
"pairedGlucoseLabel": "Glucose uit dezelfde meting ({unit})",
"pairedGlucoseHint": "Wordt als eigen glucosemeting met hetzelfde tijdstip opgeslagen; uit het paar volgt de glucose-ketonindex.",
"typeTotalBodyWater": "Totaal lichaamswater",
"typeBoneMass": "Botmassa",
"typeOxygenSaturation": "Zuurstofsaturatie",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glucose",
"navBloodKetones": "Ketonen",
"bloodKetones": {
"title": "Bloedketonen",
"description": "Je bèta-hydroxybutyraatmetingen in de loop van de tijd, in {unit}.",
"chartTitle": "Bloedketonen",
"emptyState": {
"title": "Nog geen ketonmetingen",
"description": "Ketonmetingen verschijnen hier zodra je er een vastlegt, los of met de glucose uit dezelfde vingerprik.",
"cta": "Ketonen vastleggen"
},
"gki": {
"description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.",
"emptyTitle": "Nog geen index",
"emptyDescription": "Hij verschijnt zodra een ketonmeting boven nul een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.",
"latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Totaal lichaamswater",
"description": "Je totale lichaamswater in de loop van de tijd, in kilogram.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Discreet (●)",
"DESCRIPTIVE": "Beschrijvend"
},
"forecasts": "Weergave van voorspellingen",
"forecastStyle": {
"EXACT": "Verwachte dagen",
"MARGIN": "Verwachte dagen met marge",
"BAND": "Eén bereik"
},
"forecastStyleHint": {
"EXACT": "Alleen de verwachte dagen.",
"MARGIN": "De verwachte dagen, met de dagen waarop de menstruatie eerder of later kan beginnen als aparte afspraken.",
"BAND": "Eén afspraak over alle dagen waarop de menstruatie 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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Geen eenheid",
"noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL."
},
"filter": {
"searchPlaceholder": "Biomarkers zoeken…",
"searchLabel": "Biomarkers zoeken",
"clearSearch": "Zoekopdracht wissen",
"lastMeasured": "Laatst gemeten",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"statusAbnormal": "Buiten bereik",
"categoryLabel": "Categorie",
"categoryAll": "Alle categorieën",
"markerCountOne": "{count} biomarker",
"markerCountFew": "{count} biomarkers",
"markerCountOther": "{count} biomarkers",
"noMatchesTitle": "Geen passende biomarkers",
"noMatchesDescription": "Probeer je zoekopdracht of filters aan te passen."
}
},
"modules": {
@@ -11497,6 +11548,9 @@
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (België, dag en nacht, gratis, anoniem)"
},
"suicidePrevention109": {
"name": "Zelfmoordpreventielijn 109 (Zuid-Korea, dag en nacht, gratis)"
}
},
"instrumentDescription": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Alle {count} waarden",
"valuesAllOther": "Alle {count} waarden",
"valuesFewer": "Minder waarden",
"gkiPair": "Glucose {glucose} {unit} / ketonen {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Laatste van {count}",
"gkiCountFew": "Laatste van {count}",
"gkiCountOther": "Laatste van {count}",
"countEntriesOne": "1 registratie",
"countEntriesFew": "{count} registraties",
"countEntriesOther": "{count} registraties",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Sen",
"typeSteps": "Kroki",
"typeBloodGlucose": "Glikemia",
"typeBloodKetones": "Ketony we krwi",
"glucoseKetoneIndex": "Indeks glukozowo-ketonowy",
"pairedGlucoseLabel": "Glukoza z tego samego pomiaru ({unit})",
"pairedGlucoseHint": "Zapisywana jako osobny odczyt glukozy z tą samą godziną; z tej pary obliczany jest indeks glukozowo-ketonowy.",
"typeTotalBodyWater": "Całkowita woda w organizmie",
"typeBoneMass": "Masa kostna",
"typeOxygenSaturation": "Saturacja tlenem",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glukoza",
"navBloodKetones": "Ketony",
"bloodKetones": {
"title": "Ketony we krwi",
"description": "Twoje odczyty beta-hydroksymaślanu w czasie, w {unit}.",
"chartTitle": "Ketony we krwi",
"emptyState": {
"title": "Brak jeszcze odczytów ketonów",
"description": "Odczyty ketonów pojawią się tutaj po zapisaniu jednego, osobno lub z glukozą z tego samego nakłucia.",
"cta": "Zapisz ketony"
},
"gki": {
"description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.",
"emptyTitle": "Brak jeszcze indeksu",
"emptyDescription": "Pojawi się, gdy do odczytu ketonów powyżej zera będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.",
"latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Całkowita woda w organizmie",
"description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Dyskretne (●)",
"DESCRIPTIVE": "Opisowe"
},
"forecasts": "Wygląd prognoz",
"forecastStyle": {
"EXACT": "Przewidywane dni",
"MARGIN": "Przewidywane dni z marginesem",
"BAND": "Jeden zakres"
},
"forecastStyleHint": {
"EXACT": "Tylko przewidywane dni.",
"MARGIN": "Przewidywane dni, a jako osobne wydarzenia dni, w które miesiączka może zacząć się wcześniej lub później.",
"BAND": "Jedno wydarzenie obejmujące wszystkie dni, na które może przypaść miesiączka."
},
"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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Bez jednostki",
"noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL."
},
"filter": {
"searchPlaceholder": "Szukaj biomarkerów…",
"searchLabel": "Szukaj biomarkerów",
"clearSearch": "Wyczyść wyszukiwanie",
"lastMeasured": "Ostatni pomiar",
"statusLabel": "Status",
"statusAll": "Wszystkie statusy",
"statusAbnormal": "Poza zakresem",
"categoryLabel": "Kategoria",
"categoryAll": "Wszystkie kategorie",
"markerCountOne": "{count} biomarker",
"markerCountFew": "{count} biomarkery",
"markerCountOther": "{count} biomarkerów",
"noMatchesTitle": "Brak pasujących biomarkerów",
"noMatchesDescription": "Spróbuj zmienić zapytanie lub zresetować filtry."
}
},
"modules": {
@@ -11497,6 +11548,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": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Wszystkie {count} wartości",
"valuesAllOther": "Wszystkie {count} wartości",
"valuesFewer": "Mniej wartości",
"gkiPair": "Glukoza {glucose} {unit} / ketony {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Ostatni z {count}",
"gkiCountFew": "Ostatni z {count}",
"gkiCountOther": "Ostatni z {count}",
"countEntriesOne": "1 wpis",
"countEntriesFew": "{count} wpisy",
"countEntriesOther": "{count} wpisów",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Sono",
"typeSteps": "Passos",
"typeBloodGlucose": "Glicemia",
"typeBloodKetones": "Cetonas no sangue",
"glucoseKetoneIndex": "Índice glicose-cetona",
"pairedGlucoseLabel": "Glicose da mesma medição ({unit})",
"pairedGlucoseHint": "Salva como uma leitura de glicose própria no mesmo horário; o índice glicose-cetona é calculado a partir do par.",
"typeTotalBodyWater": "Água corporal total",
"typeBoneMass": "Massa óssea",
"typeOxygenSaturation": "Saturação de oxigênio",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Glicose",
"navBloodKetones": "Cetonas",
"bloodKetones": {
"title": "Cetonas no sangue",
"description": "Suas leituras de beta-hidroxibutirato ao longo do tempo, em {unit}.",
"chartTitle": "Cetonas no sangue",
"emptyState": {
"title": "Ainda não há leituras de cetonas",
"description": "As leituras de cetonas aparecem aqui assim que você registra uma, sozinha ou com a glicose da mesma picada.",
"cta": "Registrar cetonas"
},
"gki": {
"description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.",
"emptyTitle": "Ainda não há índice",
"emptyDescription": "Ele aparece quando uma leitura de cetonas acima de zero tem uma de glicose feita no mesmo horário ou em até 15 minutos.",
"latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Água Corporal Total",
"description": "Sua água corporal total ao longo do tempo, em quilogramas.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descritivo"
},
"forecasts": "Como as previsões aparecem",
"forecastStyle": {
"EXACT": "Dias previstos",
"MARGIN": "Dias previstos e margem",
"BAND": "Um único intervalo"
},
"forecastStyleHint": {
"EXACT": "Só os dias previstos.",
"MARGIN": "Os dias previstos, com os dias em que a menstruação pode começar antes ou depois como eventos separados.",
"BAND": "Um único evento cobrindo todos os dias em que a menstruação 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}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Sem unidade",
"noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL."
},
"filter": {
"searchPlaceholder": "Buscar biomarcadores…",
"searchLabel": "Buscar biomarcadores",
"clearSearch": "Limpar busca",
"lastMeasured": "Última medição",
"statusLabel": "Status",
"statusAll": "Todos os status",
"statusAbnormal": "Fora da faixa",
"categoryLabel": "Categoria",
"categoryAll": "Todas as categorias",
"markerCountOne": "{count} biomarcador",
"markerCountFew": "{count} biomarcadores",
"markerCountOther": "{count} biomarcadores",
"noMatchesTitle": "Nenhum biomarcador encontrado",
"noMatchesDescription": "Tente ajustar sua busca ou os filtros."
}
},
"modules": {
@@ -11497,6 +11548,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": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Todos os {count} valores",
"valuesAllOther": "Todos os {count} valores",
"valuesFewer": "Menos valores",
"gkiPair": "Glicose {glucose} {unit} / cetonas {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Último de {count}",
"gkiCountFew": "Último de {count}",
"gkiCountOther": "Último de {count}",
"countEntriesOne": "{count} entrada",
"countEntriesFew": "{count} entradas",
"countEntriesOther": "{count} entradas",
+60 -1
View File
@@ -813,6 +813,10 @@
"typeSleep": "Сон",
"typeSteps": "Шаги",
"typeBloodGlucose": "Глюкоза крови",
"typeBloodKetones": "Кетоны в крови",
"glucoseKetoneIndex": "Глюкозо-кетоновый индекс",
"pairedGlucoseLabel": "Глюкоза из того же измерения ({unit})",
"pairedGlucoseHint": "Сохраняется как отдельное измерение глюкозы с тем же временем; по этой паре рассчитывается глюкозо-кетоновый индекс.",
"typeTotalBodyWater": "Общая вода в организме",
"typeBoneMass": "Костная масса",
"typeOxygenSaturation": "Насыщение кислородом",
@@ -4002,6 +4006,23 @@
}
},
"navBloodGlucose": "Глюкоза",
"navBloodKetones": "Кетоны",
"bloodKetones": {
"title": "Кетоны в крови",
"description": "Ваши измерения бета-гидроксибутирата в динамике, в {unit}.",
"chartTitle": "Кетоны в крови",
"emptyState": {
"title": "Измерений кетонов пока нет",
"description": "Измерения кетонов появятся здесь, как только вы запишете одно, отдельно или вместе с глюкозой из того же прокола.",
"cta": "Записать кетоны"
},
"gki": {
"description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.",
"emptyTitle": "Индекса пока нет",
"emptyDescription": "Он появится, когда к измерению кетонов выше нуля будет измерение глюкозы в то же время или в пределах 15 минут.",
"latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}."
}
},
"totalBodyWater": {
"title": "Общая вода в организме",
"description": "Ваша общая вода в организме в динамике, в килограммах.",
@@ -10056,6 +10077,17 @@
"DISCREET": "Незаметно (●)",
"DESCRIPTIVE": "Подробно"
},
"forecasts": "Вид прогноза",
"forecastStyle": {
"EXACT": "Ожидаемые дни",
"MARGIN": "Ожидаемые дни с запасом",
"BAND": "Один промежуток"
},
"forecastStyleHint": {
"EXACT": "Только ожидаемые дни.",
"MARGIN": "Ожидаемые дни, а отдельными событиями дни, когда месячные могут начаться раньше или позже.",
"BAND": "Одно событие на все дни, на которые могут прийтись месячные."
},
"fertile": "Фертильное окно",
"fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.",
"rotate": "Обновить ссылку",
@@ -10075,6 +10107,7 @@
"periodEstimate": "Месячные (оценка)",
"periodLogged": "Месячные (записаны)",
"fertileEstimate": "Фертильное окно (оценка)",
"periodPossible": "Возможны месячные",
"likelyStart": "Наиболее вероятное начало {date}, {band}",
"bandOne": "±{count} день",
"bandFew": "±{count} дня",
@@ -10584,7 +10617,9 @@
"openExpanded": "Все",
"openFlagged": "Только вне диапазона",
"openCollapsed": "Ни одной",
"orderWithinGroups": "Порядок действует внутри каждой категории."
"orderWithinGroups": "Порядок действует внутри каждой категории.",
"orderHeading": "Порядок категорий",
"orderDefault": "Пока вы ничего не переместили, встроенные категории идут в постоянном порядке, ваши собственные по алфавиту, а {other} в конце."
},
"sourceRange": {
"fromReport": "Диапазон из отчёта {range}",
@@ -10664,6 +10699,22 @@
"unit": {
"none": "Без единицы",
"noneHint": "Для коэффициента или индекса, например МНО или холестерин/ЛПВП."
},
"filter": {
"searchPlaceholder": "Поиск биомаркеров…",
"searchLabel": "Поиск биомаркеров",
"clearSearch": "Очистить поиск",
"lastMeasured": "Последнее измерение",
"statusLabel": "Статус",
"statusAll": "Все статусы",
"statusAbnormal": "Вне нормы",
"categoryLabel": "Категория",
"categoryAll": "Все категории",
"markerCountOne": "{count} биомаркер",
"markerCountFew": "{count} биомаркера",
"markerCountOther": "{count} биомаркеров",
"noMatchesTitle": "Биомаркеры не найдены",
"noMatchesDescription": "Попробуйте изменить поисковый запрос или фильтры."
}
},
"modules": {
@@ -11497,6 +11548,9 @@
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Бельгия, круглосуточно, бесплатно, анонимно)"
},
"suicidePrevention109": {
"name": "Линия профилактики самоубийств 109 (Южная Корея, круглосуточно, бесплатно)"
}
},
"instrumentDescription": {
@@ -13672,6 +13726,11 @@
"valuesAllFew": "Все {count} значения",
"valuesAllOther": "Все {count} значений",
"valuesFewer": "Меньше значений",
"gkiPair": "Глюкоза {glucose} {unit} / кетоны {ketones} {unit}",
"gkiPairShort": "{glucose} / {ketones} {unit}",
"gkiCountOne": "Последний из {count}",
"gkiCountFew": "Последний из {count}",
"gkiCountOther": "Последний из {count}",
"countEntriesOne": "{count} запись",
"countEntriesFew": "Записей: {count}",
"countEntriesOther": "Записей: {count}",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.47.1",
"version": "1.47.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+5 -5
View File
@@ -37,7 +37,7 @@ overrides:
brace-expansion@>=4.0.0 <5.0.12: ^5.0.12
undici@>=7.0.0 <7.30.1: ^7.30.1
vite@>=8.0.0 <8.1.6: ^8.1.6
node-forge@<1.4.2: ^1.4.2
node-forge@<1.4.3: ^1.4.3
patchedDependencies:
'@stryker-mutator/vitest-runner@10.0.0': 9ad3e9855fd9781fd36e872ed42c5f934f91b962d6830e6cc56d56bf16e1dabd
@@ -5559,8 +5559,8 @@ packages:
encoding:
optional: true
node-forge@1.4.2:
resolution: {integrity: sha512-rRtAR5XLJ8gCKd9du4qrKAsgCG1ObGP4gLeNJMa4vYSeq4bmBMOJk5IHkrviBV0q7uoAJlVd8HEQYQSVUb4FGQ==}
node-forge@1.4.3:
resolution: {integrity: sha512-P6wzfh6t70avmV8FrVEAd/36sOPJQ+hkaJVX1Laap9J/77m7M0eyozDvc8SwnDf4P7MP94gwbAAo0rg3YVXT+g==}
engines: {node: '>= 6.13.0'}
node-releases@2.0.58:
@@ -8653,7 +8653,7 @@ snapshots:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
jsonwebtoken: 9.0.3
node-forge: 1.4.2
node-forge: 1.4.3
verror: 1.10.1
transitivePeerDependencies:
- supports-color
@@ -12760,7 +12760,7 @@ snapshots:
dependencies:
whatwg-url: 5.0.0
node-forge@1.4.2: {}
node-forge@1.4.3: {}
node-releases@2.0.58: {}
+6 -3
View File
@@ -114,9 +114,12 @@ overrides:
"vite@>=8.0.0 <8.1.6": "^8.1.6"
# GHSA-86w9-cpqp-85rv, GHSA-7wch-p9px-956x, GHSA-jc66-7q78-782g,
# GHSA-8hrf-8g6f-7xwp, GHSA-hqq3-7jhf-c5jx, GHSA-5x38-2vff-x55p: signature,
# certificate-chain and TLS-record flaws in node-forge <= 1.4.0. It arrives
# only through @parse/node-apn; 1.4.2 is the release node-apn 8.1.1 pins.
"node-forge@<1.4.2": "^1.4.2"
# certificate-chain and TLS-record flaws in node-forge <= 1.4.0;
# GHSA-3cc4-3294-vgjf, GHSA-6889-vf6x-f3g5, GHSA-w4xr-hqv8-ch44,
# GHSA-6cv4-p8m7-4r25 and GHSA-j8rw-jp99-435v in <= 1.4.2. It arrives only
# through @parse/node-apn, which pins 1.4.2 exactly; the app signs APNs
# requests with a token key and never reaches its certificate path.
"node-forge@<1.4.3": "^1.4.3"
allowBuilds:
"@prisma/engines": true
@@ -0,0 +1,33 @@
-- How the cycle calendar feed draws a forecast period (#1350):
--
-- EXACT the calculated period only, no margin
-- MARGIN the calculated period, plus the days it may start earlier or end
-- later as separate "possible" events
-- BAND one event covering every day the period may fall on
--
-- The column defaults to BAND, which is what every feed drew until now, so an
-- existing subscription keeps exactly the events it had. A new subscription is
-- given MARGIN by the route that mints it, not by this default.
--
-- One DO block, so the file is one statement that rolls back whole and the
-- lock wait on `cycle_calendar_feeds` is bounded. The type is created only when
-- missing and the column only when missing, so a replay is harmless.
-- Reversibility: dropping the column and the type returns every feed to the
-- single-band forecast.
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_forecast_style'
) THEN
CREATE TYPE "cycle_calendar_feed_forecast_style" AS ENUM ('EXACT', 'MARGIN', 'BAND');
END IF;
ALTER TABLE "cycle_calendar_feeds"
ADD COLUMN IF NOT EXISTS "forecast_style" "cycle_calendar_feed_forecast_style" NOT NULL DEFAULT 'BAND';
END
$migration$;
@@ -0,0 +1,12 @@
-- Extend `measurement_type` with blood ketones (#1367):
--
-- BLOOD_KETONES <- beta-hydroxybutyrate from a blood ketone meter (mmol/L)
--
-- Purely additive: no existing row carries the value, and nothing in this
-- file uses it, so the rule that a value added in a transaction cannot be used
-- in that same transaction never comes into play. IF NOT EXISTS keeps a replay
-- on a partially applied database harmless. Enum values cannot be dropped in
-- PostgreSQL, so there is no down path. The glucose-ketone index is computed
-- at read time and has no column.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_KETONES';
+22 -4
View File
@@ -1816,6 +1816,10 @@ enum MeasurementType {
// v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED,
// never folded into it and never summed with it into a total.
BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM.
// v1.47.2 — blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick
// meter, typed by hand. The glucose-ketone index is derived from this and
// BLOOD_GLUCOSE at read time and never stored.
BLOOD_KETONES // mmol/L — capillary or venous beta-hydroxybutyrate (BHB); spot reading, never summed.
@@map("measurement_type")
}
@@ -8343,6 +8347,17 @@ enum CycleCalendarFeedTitleMode {
@@map("cycle_calendar_feed_title_mode")
}
/// #1350 — how the feed draws a forecast period. EXACT is the calculated
/// period alone; MARGIN adds the days it may start earlier or end later as
/// separate events; BAND is one event over every day it may fall on.
enum CycleCalendarFeedForecastStyle {
EXACT
MARGIN
BAND
@@map("cycle_calendar_feed_forecast_style")
}
/// #1350 — a subscribable calendar feed of one record's cycle forecast.
///
/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is
@@ -8389,12 +8404,15 @@ model CycleCalendarFeed {
/// Forecast cycles ahead (1..6). The feed caps itself further once the
/// band grows past ±10 days or half a cycle.
horizonCycles Int @default(3) @map("horizon_cycles")
horizonCycles Int @default(3) @map("horizon_cycles")
/// Observed cycles included as history (0..12).
historyCycles Int @default(0) @map("history_cycles")
historyCycles Int @default(0) @map("history_cycles")
/// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only.
includeFertile Boolean @default(false) @map("include_fertile")
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
includeFertile Boolean @default(false) @map("include_fertile")
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
/// BAND by default so a feed minted before the option keeps its events. A
/// new feed is minted with MARGIN by the subscription route.
forecastStyle CycleCalendarFeedForecastStyle @default(BAND) @map("forecast_style")
user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade)
createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade)
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.47.1";
/* @sw-version-fallback */ "v1.47.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+26 -14
View File
@@ -47,25 +47,37 @@ describe("public/sw.js CACHE_VERSION wiring", () => {
});
});
// Each test starts a fresh node process. On a busy CI runner one start
// has taken over six seconds, past the five-second default.
const SPAWN_TIMEOUT_MS = 30_000;
describe("scripts/generate-sw-version.mjs", () => {
afterAll(() => {
// Don't leave the generated artefact behind for the next test run.
if (existsSync(GENERATED_PATH)) rmSync(GENERATED_PATH);
});
it("writes self.__APP_VERSION__ matching package.json", () => {
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
expect(existsSync(GENERATED_PATH)).toBe(true);
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8"));
const generated = readFileSync(GENERATED_PATH, "utf8");
expect(generated).toContain(`self.__APP_VERSION__ = "v${pkg.version}"`);
});
it(
"writes self.__APP_VERSION__ matching package.json",
() => {
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
expect(existsSync(GENERATED_PATH)).toBe(true);
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8"));
const generated = readFileSync(GENERATED_PATH, "utf8");
expect(generated).toContain(`self.__APP_VERSION__ = "v${pkg.version}"`);
},
SPAWN_TIMEOUT_MS,
);
it("re-running the script is idempotent (overwrites with same content)", () => {
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
const a = readFileSync(GENERATED_PATH, "utf8");
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
const b = readFileSync(GENERATED_PATH, "utf8");
expect(b).toBe(a);
});
it(
"re-running the script is idempotent (overwrites with same content)",
() => {
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
const a = readFileSync(GENERATED_PATH, "utf8");
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
const b = readFileSync(GENERATED_PATH, "utf8");
expect(b).toBe(a);
},
SPAWN_TIMEOUT_MS,
);
});
@@ -11,7 +11,7 @@
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1086,
"components/insights/insights-tab-strip.tsx": 1087,
"components/measurements/measurement-form.tsx": 815,
"components/medications/detail/medication-detail-tabs.tsx": 1157,
"components/medications/medication-table.tsx": 853,
@@ -39,7 +39,7 @@
"lib/daily/digest.ts": 1317,
"lib/daily/load-digest.ts": 1000,
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 976,
"lib/dashboard-layout.ts": 977,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/export/backup-plan.ts": 838,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5476,
"total": 5475,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -440,7 +440,7 @@
"lib/jobs/__tests__/insight-status-generate.test.ts": 2,
"lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2,
"lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2,
"lib/jobs/__tests__/measurement-reminder.test.ts": 33,
"lib/jobs/__tests__/measurement-reminder.test.ts": 32,
"lib/jobs/__tests__/medication-intake-import.test.ts": 1,
"lib/jobs/__tests__/medication-reminder-check.test.ts": 27,
"lib/jobs/__tests__/mood-reminder.test.ts": 15,
+11 -3
View File
@@ -118,13 +118,20 @@ beforeEach(() => {
});
describe("the calendar feed body", () => {
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
"carries no day-log content in %s mode",
async (titleMode) => {
it.each(
(["DISCREET", "DESCRIPTIVE"] as const).flatMap((titleMode) =>
(["EXACT", "MARGIN", "BAND"] as const).map(
(forecastStyle) => [titleMode, forecastStyle] as const,
),
),
)(
"carries no day-log content in %s mode, %s forecasts",
async (titleMode, forecastStyle) => {
const built = await buildCycleFeed(
{
...FEED,
titleMode,
forecastStyle,
historyCycles: 12,
horizonCycles: 6,
includeFertile: true,
@@ -146,6 +153,7 @@ describe("the calendar feed body", () => {
{
...FEED,
titleMode: "DESCRIPTIVE",
forecastStyle: "MARGIN",
historyCycles: 6,
includeFertile: true,
},
@@ -149,6 +149,8 @@ const ALLOWED_SQL: Record<string, string> = {
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
"lib/dashboard/summary-reads.ts::1":
"Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.",
"lib/measurements/read-glucose-ketone-index.ts::1":
"Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.",
"lib/dashboard/summary-reads.ts::2":
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
};
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(111);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
expect(ALL_LEAF_IDS.length).toBe(112);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
@@ -93,9 +93,10 @@ const READERS: Record<string, Reader> = {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("unknown"),
},
// Code lines only: a comment that names `category.status` is not a reader.
"overview:categories[].status": {
file: GROUPS,
pattern: /\bcategory\.status\s*===\s*"above"/,
pattern: /^(?![ \t]*(?:\/\/|\/?\*)).*\bcategory\.status\b/m,
},
"biomarkers:customPanels[]": {
file: FORM_PANEL,
@@ -132,8 +133,9 @@ describe("labs categories response/consumer guard", () => {
const owned = ownedLeaves();
it("walks the schemas it guards", () => {
// Nine category leaves, `grouped`, the panels and the adopted panel, plus
// the exempt marker rows; far fewer means the walk broke.
// Category leaves (key, label, markerKeys[], counts.*, status), `grouped`,
// the panels and the adopted panel, plus the exempt marker rows; far fewer
// means the walk broke.
expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe(
Object.keys(READERS).length,
);
@@ -159,8 +161,41 @@ describe("labs categories response/consumer guard", () => {
expect(
COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
"category.otherStatus",
),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
" // Consumers supply category.status for unfiltered categories.",
),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
" status: statusProp ?? category.status,",
),
).toBe(true);
expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe(
false,
);
});
it("guards filtered summary branch for counts and status", () => {
// When filters narrow the visible rows, LabGroupHeader reads `summary.counts`
// and `summary.status` (summarised client-side), rather than unfiltered server category totals.
const groupsSource = readSource(GROUPS, [
/\bsummary\.counts\b/,
/\bsummary\.status\b/,
]);
expect(/\bsummary\.counts\b/.test(groupsSource)).toBe(true);
expect(/\bsummary\.status\b/.test(groupsSource)).toBe(true);
const listSource = readSource(LIST, [
/\bsummariseGroupRows\b/,
/\bsummary\?\.status\b/,
]);
expect(/\bsummariseGroupRows\b/.test(listSource)).toBe(true);
expect(/\bsummary\?\.status\b/.test(listSource)).toBe(true);
});
});
+1 -1
View File
@@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/labs/overview/route.ts": {
domain: "labs",
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.",
},
"app/api/biomarkers/route.ts": {
domain: "labs",
@@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason: "the onboarding unit picker: its options are the two units",
},
"components/measurements/measurement-form.tsx": {
lines: 11,
lines: 12,
reason:
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
},
@@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason: "the legacy export endpoint's canonical storage-unit contract",
},
"lib/measurements/series/kinds.ts": {
lines: 4,
lines: 5,
reason:
"canonical wire units; glucose is overridden to the reader's unit per request",
"canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice",
},
"lib/doctor-report/stat-display.ts": {
lines: 3,
@@ -4,11 +4,15 @@
*
* One transaction: the live feed is revoked and its successor minted with the
* same options, so there is never a moment with two live links or with none.
* The forecast style is copied too: a feed minted before that option keeps
* BAND rather than taking a new feed's default.
* Calendars holding the old link stop updating on their next poll. The new
* URL is returned exactly once. Two rotations racing both revoke the same
* feed, and the second successor then meets the one-live-feed index: that
* one gets the mint path's 409 `cycle.feed.exists`, and the winner's link
* stands.
* URL is returned exactly once. Of two rotations racing, the second finds
* the feed already revoked (or its successor meets the one-live-feed index)
* and gets the mint path's 409 `cycle.feed.exists`; the winner's link
* stands. A subscription turned off during a rotation stays off, whichever
* commits first, and the rotation then answers 404 `cycle.feed.none`; an
* option changed during one lands on the link that stays live.
*
* Same gates as turning the subscription on: cookie session only, the owner or
* the guardian of a managed profile (`requireGuardianAuth`), a fresh proof,
@@ -33,7 +37,11 @@ import {
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
import {
FEED_SUMMARY_SELECT,
findLiveFeed,
toFeedSummary,
} from "@/lib/cycle/feed/status";
import { mintFeedToken } from "@/lib/cycle/feed/token";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
@@ -70,21 +78,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
const minted = mintFeedToken();
const rotated = await prisma
.$transaction(async (tx) => {
const live = await tx.cycleCalendarFeed.findFirst({
const found = await tx.cycleCalendarFeed.findFirst({
where: { userId: user.id, revokedAt: null },
select: { id: true },
});
if (!found) return null;
// Only while it is still live: a subscription turned off after the
// read above stays off instead of coming back under a new link.
const revoked = await tx.cycleCalendarFeed.updateMany({
where: { id: found.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (revoked.count === 0) return "raced" as const;
// The options are read after the revoke holds the row, so a change
// that committed in between is the one the new link carries, and one
// that comes later waits and then finds the new link.
const live = await tx.cycleCalendarFeed.findUniqueOrThrow({
where: { id: found.id },
select: {
id: true,
horizonCycles: true,
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
},
});
if (!live) return null;
await tx.cycleCalendarFeed.update({
where: { id: live.id },
data: { revokedAt: new Date() },
});
const created = await tx.cycleCalendarFeed.create({
data: {
userId: user.id,
@@ -95,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
forecastStyle: live.forecastStyle,
},
select: FEED_SUMMARY_SELECT,
});
@@ -113,7 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (isP2002(err)) return "raced" as const;
throw err;
});
if (rotated === "raced") return feedExists();
// Lost to another rotation, the link that won stands; lost to the
// subscription being turned off, there is none.
if (rotated === "raced") {
return (await findLiveFeed(user.id)) ? feedExists() : noLiveFeed();
}
if (!rotated) return noLiveFeed();
return apiSuccess(
+62 -19
View File
@@ -59,7 +59,10 @@ import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
import {
NEW_FEED_FORECAST_STYLE,
cycleFeedOptionsPatchSchema,
} from "@/lib/validations/cycle-feed";
export const GET = apiHandler(async () => {
const auth = await requireGuardianAuth();
@@ -110,6 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: {
userId: user.id,
createdById: actor.id,
forecastStyle: NEW_FEED_FORECAST_STYLE,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
},
@@ -166,7 +170,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
);
}
const live = await findLiveFeed(user.id);
let live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
const data = {
@@ -180,10 +184,29 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
? { includeFertile: input.includeFertile }
: {}),
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
...(input.forecastStyle !== undefined
? { forecastStyle: input.forecastStyle }
: {}),
};
const updated = await prisma.cycleCalendarFeed.update({
where: { id: live.id },
// Only while the feed is still the live one. A rotation that committed
// after the read above revoked it and copied its old options onto the new
// link; the change then goes to that link, once. Updating the revoked row
// would answer 200 with the old options left in force.
let written = await prisma.cycleCalendarFeed.updateMany({
where: { id: live.id, revokedAt: null },
data,
});
if (written.count === 0) {
live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
written = await prisma.cycleCalendarFeed.updateMany({
where: { id: live.id, revokedAt: null },
data,
});
if (written.count === 0) return feedExists();
}
const updated = await prisma.cycleCalendarFeed.findUniqueOrThrow({
where: { id: live.id },
select: FEED_SUMMARY_SELECT,
});
@@ -197,12 +220,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
forecastStyle: live.forecastStyle,
},
after: {
horizonCycles: updated.horizonCycles,
historyCycles: updated.historyCycles,
includeFertile: updated.includeFertile,
titleMode: updated.titleMode,
forecastStyle: updated.forecastStyle,
},
}),
},
@@ -220,25 +245,43 @@ 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.
await auditLog("cycle.feed.revoke", {
userId: user.id,
details: {
feedId: live.id,
...destroyedDetails({
model: "CycleCalendarFeed",
id: live.id,
label: live.tokenPrefix,
effectiveAt: live.createdAt,
}),
},
//
// The trail names the feeds this request revoked, read back by the one
// stamp both passes wrote: a racing rotation may have revoked the feed
// read above itself, leaving its successor as the one turned off here.
const revoked = await prisma.cycleCalendarFeed.findMany({
where: { userId: user.id, revokedAt },
select: { id: true, tokenPrefix: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
for (const feed of revoked) {
await auditLog("cycle.feed.revoke", {
userId: user.id,
details: {
feedId: feed.id,
...destroyedDetails({
model: "CycleCalendarFeed",
id: feed.id,
label: feed.tokenPrefix,
effectiveAt: feed.createdAt,
}),
},
});
}
return apiSuccess({ revoked: true });
});
+28 -4
View File
@@ -2,10 +2,12 @@ import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { categoriseOverview } from "@/lib/labs/marker-category";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { resolveModuleListLayout } from "@/lib/settings/module-list-layout";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
/**
@@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
* `categories` and `grouped` are the list's categories, resolved here once
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
* combined status. Labels follow the viewer's locale, so on a managed record
* the caller's own language wins, not the record owner's.
* the caller's own language wins, not the record owner's. The order is the
* default one with the caller's `categoryOrder` (the labs list layout,
* `PUT /api/settings/module-list-layout`) applied, for the same reason: it is
* the viewer's presentation, so a person acting for another record sees the
* categories in their own order. A client renders the order as given.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user, actor } = await requireRecordAuth("read", "labs");
@@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const markers = await loadLabOverview(user.id);
const [markers, layoutRow] = await Promise.all([
loadLabOverview(user.id),
prisma.user.findUnique({
where: { id: actor.id },
select: { moduleListLayoutJson: true },
}),
]);
const { categoryOrder } = resolveModuleListLayout(
layoutRow?.moduleListLayoutJson,
).lists.labs;
const locale = await resolveServerLocale({
request,
userLocale: actor.locale ?? null,
});
const { categories, grouped } = categoriseOverview(markers, locale);
const { categories, grouped } = categoriseOverview(
markers,
locale,
categoryOrder,
);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length, categories: categories.length, grouped },
meta: {
markers: markers.length,
categories: categories.length,
grouped,
categoryOrderLength: categoryOrder.length,
},
});
return apiSuccess({ markers, categories, grouped });
});
@@ -272,8 +272,13 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
});
it("re-enabling a disabled short-cycle reminder schedules it from now", async () => {
const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false };
it("re-enabling a disabled weekly calendar rule schedules it from now", async () => {
const weekly = {
...BASE_ROW,
intervalDays: null,
rrule: "FREQ=WEEKLY",
enabled: false,
};
findFirstMock.mockResolvedValue(weekly);
const res = await PATCH(makeRequest({ enabled: true }), params);
@@ -303,6 +308,26 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect("nextDueAt" in persisted).toBe(false);
});
it("re-enabling a missed short rolling reminder keeps its open slot", async () => {
// Every two days, never measured: switching it off and on again is not
// measuring it either.
findFirstMock.mockResolvedValue({
...BASE_ROW,
intervalDays: 2,
enabled: false,
});
const res = await PATCH(makeRequest({ enabled: true }), params);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(persisted.enabled).toBe(true);
expect("nextDueAt" in persisted).toBe(false);
});
it("reads a resent first due date on the same calendar day as unchanged", async () => {
// A booster row stores the dose instant plus N days; the web form sends
// the same day back as local midnight. Same day in the profile zone, so
@@ -219,7 +219,7 @@ export const PATCH = apiHandler(
const reEnabled = updateData.enabled === true && !existing.enabled;
// Re-enabling a reminder that stays due after it is sent keeps the slot it
// was on, overdue or not: switching it off and on again is not doing it.
// A short-cycle reminder is scheduled from now, as before.
// A calendar rule that recurs within the week is scheduled from now.
const keepsOpenSlot =
reEnabled &&
!cadenceChanged &&
@@ -58,14 +58,15 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: Request) => {
const { user } = await requireActorAuth();
// 500 ids of up to 64 characters plus the envelope.
// 500 ids plus 200 category keys of up to 64 characters, plus the envelope.
const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, {
maxBytes: 48 * 1024,
maxBytes: 64 * 1024,
action: "settings.module-list-layout.validation-failed",
});
if (!parsed.ok) return parsed.error;
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } =
parsed.data;
// Preserve-when-absent: one stored read fills whichever field the body
// left out, and every other list stays as stored. Only fields a person has
// chosen are written, so a field never picked keeps following the default.
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => {
...(sortDir ? { sortDir } : {}),
...(groupBy ? { groupBy } : {}),
...(groupsOpen ? { groupsOpen } : {}),
...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}),
};
const next = { ...stored, [list]: nextChoices };
await tx.user.update({
@@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => {
groupBy: layout.lists[list].groupBy,
groupsOpen: layout.lists[list].groupsOpen,
orderLength: layout.lists[list].order.length,
categoryOrderLength: layout.lists[list].categoryOrder.length,
},
});
return apiSuccess(layout);
@@ -1,7 +1,7 @@
/**
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
*
* The answer is an empty page whose cursor sits a minute before `serverNow`
* The answer is an empty page whose cursor sits two minutes before `serverNow`
* in every domain (a write stamped before it and committed after it is not
* skipped), in the feed's own opaque format, and nothing is read: the point is
* to skip the history walk, so a read here would be the bug. The database is
@@ -55,7 +55,7 @@ beforeEach(() => {
});
describe("GET /api/sync/changes?start=now", () => {
it("answers an empty page with a cursor a minute before serverNow in every domain", async () => {
it("answers an empty page with a cursor two minutes before serverNow in every domain", async () => {
const before = Date.now();
const res = await GET(req("?start=now"));
const after = Date.now();
@@ -82,7 +82,7 @@ describe("GET /api/sync/changes?start=now", () => {
expect(cursor).not.toBeNull();
for (const domain of SYNC_DOMAINS) {
expect(cursor![domain], domain).toEqual({
updatedAtMs: nowMs - 60_000,
updatedAtMs: nowMs - 120_000,
id: "0",
});
}
+7 -1
View File
@@ -31,6 +31,12 @@
* - `hasMore` is true when ANY served domain still has rows past its
* page; the next `cursor` advances every domain that returned rows.
* When `hasMore` is false the client is caught up as of `serverNow`.
* A row domain that ran dry takes a watermark two minutes before
* `serverNow` (`drainedWatermark`; on the last page every one does):
* a row stamped in that stretch whose write commits after the pull
* still arrives on the next one, at the cost of reading the stretch
* twice, and a domain that stays quiet keeps a fresh watermark instead
* of ageing into `cursorExpired`.
* - `cursorExpired: true` when ANY domain watermark predates the
* tombstone-retention horizon — the client drops its cursor and does
* a clean initial sync (a deletion older than retention may have
@@ -43,7 +49,7 @@
* cycle tracking off gets both blocks empty (never a 403 — see the
* gate note in the handler) and their cursor watermarks untouched.
*
* `start=now` (#1339) answers an empty page whose cursor sits a minute
* `start=now` (#1339) answers an empty page whose cursor sits two minutes
* before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same
* opaque format, so a client that
* only wants what changes from now on (the Apple Health write-back) pages
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { GkiCard } from "@/components/insights/ketones/gki-card";
import { getUnitForType } from "@/lib/measurements/unit-map";
/** mmol/L, from the one canonical unit table; ketones have no unit choice. */
const KETONE_UNIT = getUnitForType("BLOOD_KETONES");
/**
* `/insights/ketones` (#1367).
*
* Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric
* scaffold, with the glucose-ketone index beneath the chart. Readings are
* typed off a meter, alone or together with the glucose from the same
* fingerstick, so the empty state offers manual entry. No band and no
* assessment card: what a ketone reading should be follows the plan a
* clinician sets, and the page shows the readings as recorded.
*/
export default function InsightsKetonesPage() {
return (
<HealthKitMetricPage
measurementType="BLOOD_KETONES"
insightMetric="BLOOD_KETONES"
chartKey="bloodKetones"
i18nPrefix="insights.bloodKetones"
color="var(--chart-3)"
unit={KETONE_UNIT}
yAxisUnit={KETONE_UNIT}
statFractionDigits={1}
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType="BLOOD_KETONES"
captureType="BLOOD_KETONES"
afterChart={<GkiCard />}
/>
);
}
@@ -261,6 +261,21 @@ export function MeasurementGroups({
max: show(type, stat!.max, digits),
})}
/>
{type === "BLOOD_KETONES" &&
report.glucoseKetoneIndex &&
scope.admits("BLOOD_GLUCOSE") ? (
// #1367 — the index the server derived from the ketone
// and glucose readings, beside the ketones; no unit.
<StatRow
label={t("measurements.glucoseKetoneIndex")}
value={statSummary(t, "", {
latest: fmtDigits(report.glucoseKetoneIndex.latest, 1),
avg: fmtDigits(report.glucoseKetoneIndex.avg, 1),
min: fmtDigits(report.glucoseKetoneIndex.min, 1),
max: fmtDigits(report.glucoseKetoneIndex.max, 1),
})}
/>
) : null}
{unvalidated > 0 && (
<p
className="text-muted-foreground text-xs"
@@ -0,0 +1,81 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The line takes the series colour it is given (the GKI draws in a chart
* token beside the ketone series) and keeps primary when it is given none,
* so a custom metric's chart looks as it always has.
*/
const lineProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("recharts", () => {
const Pass = ({ children }: { children?: ReactNode }) => <>{children}</>;
const Leaf = () => null;
return {
ResponsiveContainer: Pass,
ComposedChart: Pass,
CartesianGrid: Leaf,
ReferenceArea: Leaf,
ReferenceLine: Leaf,
Tooltip: Leaf,
XAxis: Leaf,
YAxis: Leaf,
Line: (props: Record<string, unknown>) => {
lineProps.push(props);
return null;
},
};
});
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CustomMetricChart } from "../custom-metric-chart";
import type { CustomMetricEntryDto } from "../types";
const NOW = Date.now();
const ENTRIES: CustomMetricEntryDto[] = [3, 2, 1].map((daysAgo, i) => ({
id: `e${i}`,
customMetricId: "",
value: 2 + i,
unit: "",
measuredAt: new Date(NOW - daysAgo * 86_400_000).toISOString(),
note: null,
createdAt: new Date(NOW).toISOString(),
}));
function lineStroke(color?: string): unknown {
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<CustomMetricChart
name="Index"
unit=""
targetLow={null}
targetHigh={null}
decimals={1}
entries={ENTRIES}
{...(color ? { color } : {})}
/>
</I18nProvider>
</QueryClientProvider>,
);
return lineProps.at(-1)?.stroke;
}
beforeEach(() => {
lineProps.length = 0;
});
describe("<CustomMetricChart> series colour", () => {
it("draws the line in the colour it is given", () => {
expect(lineStroke("var(--chart-4)")).toBe("var(--chart-4)");
});
it("keeps primary by default", () => {
expect(lineStroke()).toBe("var(--primary)");
});
});
@@ -58,6 +58,22 @@ interface ChartPoint {
value: number;
}
/** The values and any target bound, padded by a tenth of their spread. */
function yDomainOf(
points: readonly ChartPoint[],
targetLow: number | null,
targetHigh: number | null,
): [number, number] {
const candidates = points.map((p) => p.value);
if (targetLow != null) candidates.push(targetLow);
if (targetHigh != null) candidates.push(targetHigh);
if (candidates.length === 0) return [0, 1];
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
}
export function CustomMetricChart({
entries,
name,
@@ -65,6 +81,7 @@ export function CustomMetricChart({
targetLow,
targetHigh,
decimals,
color = "var(--primary)",
}: {
/** The metric's name: the day's focus label and the data table's caption. */
name: string;
@@ -74,6 +91,8 @@ export function CustomMetricChart({
targetLow: number | null;
targetHigh: number | null;
decimals: number | null;
/** The series colour; a chart token where it sits beside other series. */
color?: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -99,16 +118,10 @@ export function CustomMetricChart({
}));
}, [entries, range, nowMs]);
const yDomain = useMemo<[number, number]>(() => {
const candidates = points.map((p) => p.value);
if (targetLow != null) candidates.push(targetLow);
if (targetHigh != null) candidates.push(targetHigh);
if (candidates.length === 0) return [0, 1];
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
}, [points, targetLow, targetHigh]);
const yDomain = useMemo(
() => yDomainOf(points, targetLow, targetHigh),
[points, targetLow, targetHigh],
);
// v1.42 — each value opens its day (the calendar day of its instant in
// the record's zone) through the doors every day-linked chart has.
@@ -134,7 +147,7 @@ export function CustomMetricChart({
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
const primary = color;
return (
<div className="space-y-3">
@@ -30,6 +30,7 @@ const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "MARGIN" as const,
};
describe("<CycleCalendarFeedCard>", () => {
@@ -58,6 +59,32 @@ 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 the period 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 the period may fall on.",
);
expect(band).not.toContain("as separate entries");
});
it("explains a shorter feed in the muted tone", () => {
const html = render({
feed: FEED,
@@ -28,6 +28,7 @@ import {
type CycleFeedMinted,
type CycleFeedOptionsPatch,
type CycleFeedStatus,
type FeedForecastStyle,
type FeedTitleMode,
} from "./use-cycle-feed";
@@ -44,6 +45,7 @@ import {
const HORIZONS = [1, 3, 6] as const;
const HISTORIES = [0, 3, 6, 12] as const;
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
const FORECAST_STYLES: FeedForecastStyle[] = ["EXACT", "MARGIN", "BAND"];
/** A long URL with its middle elided, so both ends stay recognisable. */
function middleTruncate(url: string, keep = 28): string {
@@ -352,6 +354,31 @@ function FeedOptions({ status }: { status: CycleFeedStatus }) {
</NativeSelect>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-forecast">{t("cycle.feed.forecasts")}</Label>
<NativeSelect
id="cycle-feed-forecast"
value={feed.forecastStyle}
disabled={update.isPending}
aria-describedby="cycle-feed-forecast-hint"
onChange={(e) =>
save({ forecastStyle: e.target.value as FeedForecastStyle })
}
>
{FORECAST_STYLES.map((s) => (
<option key={s} value={s}>
{t(`cycle.feed.forecastStyle.${s}`)}
</option>
))}
</NativeSelect>
<p
id="cycle-feed-forecast-hint"
className="text-muted-foreground text-sm"
data-slot="cycle-feed-forecast-hint"
>
{t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)}
</p>
</div>
{status.fertileAvailable ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
+7 -1
View File
@@ -17,6 +17,7 @@ import { queryKeys } from "@/lib/query-keys";
import type { ProjectionState } from "./types";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
export interface CycleFeedSummary {
@@ -29,6 +30,7 @@ export interface CycleFeedSummary {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedStatus {
@@ -46,7 +48,11 @@ export interface CycleFeedMinted {
export type CycleFeedOptionsPatch = Partial<
Pick<
CycleFeedSummary,
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
| "horizonCycles"
| "historyCycles"
| "includeFertile"
| "titleMode"
| "forecastStyle"
>
>;
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { locales, type Locale } from "@/lib/i18n/config";
/**
* The GKI tile sits in a two-column grid, about 150 px of text wide at phone
* width and in the 420 px day layer alike. The pair line under the index has
* to fit there whole, so both readings stay readable; the worded pair goes to
* assistive tech.
*/
const POINT = {
at: "2026-11-01T08:00:00.000Z",
value: 3.8,
ketoneId: "k1",
glucoseId: "g1",
glucoseAt: "2026-11-01T08:00:00.000Z",
ketonesMmol: 0.25,
glucoseMmol: 12.5,
};
async function render(locale: Locale) {
const { I18nProvider } = await import("@/lib/i18n/context");
const { DayGkiTile } = await import("../day-gki-tile");
const messages = (await import(`../../../../messages/${locale}.json`))
.default;
return renderToStaticMarkup(
<I18nProvider initialLocale={locale} initialMessages={messages}>
<DayGkiTile points={[POINT]} />
</I18nProvider>,
);
}
function visiblePair(html: string): string {
const match = html.match(
/<span aria-hidden="true" class="[^"]*truncate text-xs[^"]*">([^<]*)<\/span>/,
);
if (!match) throw new Error(`no visible pair line in ${html}`);
return match[1]!;
}
describe("<DayGkiTile>", () => {
it.each([...locales])(
"keeps the pair line short enough for the tile in %s",
async (locale) => {
const line = visiblePair(await render(locale));
expect(line).toMatch(/12[.,]5/);
expect(line).toMatch(/0[.,]25/);
expect(line.length).toBeLessThanOrEqual(20);
},
);
it("names both readings for assistive tech", async () => {
const html = await render("en");
expect(html).toMatch(
/<span class="sr-only">Glucose 12\.5 mmol\/L \/ ketones 0\.25 mmol\/L<\/span>/,
);
});
});
+67
View File
@@ -0,0 +1,67 @@
"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,
ketoneFractionDigits,
} 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;
const pair = {
glucose: fmt.number(latest.glucoseMmol, 1),
ketones: fmt.number(
latest.ketonesMmol,
ketoneFractionDigits(latest.ketonesMmol),
),
unit: GKI_COMPONENT_UNIT,
};
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>
{points.length > 1 ? (
<span className="text-muted-foreground truncate text-xs tabular-nums">
{tCount("day.gkiCount", points.length)}
</span>
) : (
<>
{/* The tile is about 150 px of text wide: the numbers alone fit,
the worded pair is for assistive tech. */}
<span
aria-hidden="true"
className="text-muted-foreground truncate text-xs tabular-nums"
>
{t("day.gkiPairShort", pair)}
</span>
<span className="sr-only">{t("day.gkiPair", pair)}</span>
</>
)}
</Link>
</li>
);
}
+7 -3
View File
@@ -29,9 +29,9 @@ import {
type DayEvent,
type DayEventKind,
type DayNotable,
type DayResponse,
type DayRunningItem,
type DayRunningKind,
type DayValue,
} from "@/lib/day/contract";
import { visitKindWords } from "@/components/timeline/item-words";
import {
@@ -62,6 +62,7 @@ import {
WORKOUT_SPORT_KEY,
} from "./label-keys";
import { cycleRunningLine } from "./day-cycle-line";
import { DayGkiTile } from "./day-gki-tile";
import { useDayValueFormat } from "./use-day-value-format";
/**
@@ -508,12 +509,14 @@ function ValueTile({
}
export function DayValues({
values,
day,
focusTypes,
}: {
values: readonly DayValue[];
/** The day's values and its server-derived glucose-ketone index (#1367). */
day: Pick<DayResponse, "values" | "glucoseKetoneIndex">;
focusTypes: readonly string[];
}) {
const { values } = day;
const { t, tCount } = useTranslations();
const [expanded, setExpanded] = useState(false);
if (values.length === 0) return null;
@@ -537,6 +540,7 @@ export function DayValues({
selected={focusKeys.has(tile.key)}
/>
))}
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
</ul>
{rest.length > 0 ? (
<button
+1 -1
View File
@@ -296,7 +296,7 @@ export function DayView({
<DayRunning items={data.running} date={date} />
{data.values.length > 0 ? (
<div className="space-y-2.5">
<DayValues values={data.values} focusTypes={focusTypes} />
<DayValues day={data} focusTypes={focusTypes} />
<DayNotableLines
notables={otherNotables}
labelFor={labelFor}
@@ -62,8 +62,12 @@ export function useVaultFilters() {
) {
return;
}
// No field on screen (loading, an error, nothing recorded yet): the
// key stays the browser's.
const input = searchInputRef.current;
if (!input || !input.isConnected) return;
event.preventDefault();
searchInputRef.current?.focus();
input.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
@@ -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,116 @@
"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,
ketoneFractionDigits,
} 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,
ketoneFractionDigits(latest.gki.ketonesMmol),
),
unit: GKI_COMPONENT_UNIT,
date: fmt.dateTime(latest.at),
})}
</p>
<ChartErrorBoundary>
<IndexChartLazy
name={title}
unit=""
targetLow={null}
targetHigh={null}
decimals={1}
color="var(--chart-4)"
entries={points.map((p) => ({
id: p.id,
customMetricId: "",
value: p.value,
unit: "",
measuredAt: p.at,
note: null,
createdAt: p.at,
}))}
/>
</ChartErrorBoundary>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { useState } from "react";
import { renderWithApp } from "../../../../tests/browser/render";
import { LabFilterRail, type LabFilterRailProps } from "../lab-filter-rail";
import { useLabListFilters } from "../use-lab-list-filters";
function props(): LabFilterRailProps {
return {
filters: {},
searchDraft: "",
onSearchChange: vi.fn(),
searchInputRef: { current: null },
onStatusChange: vi.fn(),
onCategoryChange: vi.fn(),
onFromDayChange: vi.fn(),
onToDayChange: vi.fn(),
onResetFilters: vi.fn(),
isFiltered: false,
categories: [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
{
key: "cat_lipids",
label: "Lipids",
markerKeys: ["bm:b2"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
],
total: 5,
};
}
function centerY(element: Element): number {
const rect = element.getBoundingClientRect();
return rect.top + rect.height / 2;
}
describe("<LabFilterRail> layout", () => {
afterEach(async () => {
await page.viewport(1280, 900);
});
it("keeps search full width and wraps filter controls on phones", async () => {
await page.viewport(390, 844);
// The page gutter the labs page gives the rail (its edge bleed relies on it).
const { screen } = await renderWithApp(
<div className="px-4">
<LabFilterRail {...props()} />
</div>,
);
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
const dateRange = screen.getByRole("button", { name: "Last measured" });
const searchRect = search.element().getBoundingClientRect();
const dateRect = dateRange.element().getBoundingClientRect();
expect(searchRect.width).toBeGreaterThan(340);
expect(dateRect.top).toBeGreaterThan(searchRect.bottom);
// The filter icon leads the pill row instead of sitting on a line of
// its own between the search field and the pills.
const icon = dateRange
.element()
.closest('[data-slot="filter-bar"]')
?.querySelector("svg.lucide-list-filter, svg.lucide-funnel");
expect(icon).toBeTruthy();
expect(
Math.abs(centerY(icon!) - centerY(dateRange.element())),
).toBeLessThan(2);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390);
});
it("wraps controls cleanly without horizontal overflow at intermediate widths", async () => {
await page.viewport(1024, 768);
const { screen } = await renderWithApp(
<LabFilterRail
{...props()}
isFiltered={true}
filters={{ status: "abnormal" }}
/>,
);
const rail = screen
.getByRole("searchbox", { name: "Search biomarkers" })
.element()
.closest('[data-slot="lab-filter-rail"]');
expect(rail).not.toBeNull();
const railRect = rail!.getBoundingClientRect();
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
const searchRect = search.element().getBoundingClientRect();
expect(searchRect.width).toBeLessThan(300);
const controls = [
search.element(),
screen.getByRole("button", { name: "Last measured" }).element(),
screen.getByRole("combobox", { name: "Status: Out of range" }).element(),
screen.getByRole("combobox", { name: "Category" }).element(),
screen.getByRole("button", { name: "Reset" }).element(),
];
for (const el of controls) {
const rect = el.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(railRect.left - 1);
expect(rect.right).toBeLessThanOrEqual(railRect.right + 1);
}
});
it("keeps every control and reset visible without clipping in German at 1536px", async () => {
await page.viewport(1536, 900);
const { screen } = await renderWithApp(
<LabFilterRail
{...props()}
isFiltered={true}
filters={{ status: "abnormal" }}
/>,
{ locale: "de" },
);
const rail = screen
.getByRole("searchbox", { name: "Biomarker suchen" })
.element()
.closest('[data-slot="lab-filter-rail"]');
expect(rail).not.toBeNull();
const railRect = rail!.getBoundingClientRect();
const controls = [
screen.getByRole("searchbox", { name: "Biomarker suchen" }).element(),
screen.getByRole("button", { name: "Zuletzt gemessen" }).element(),
screen
.getByRole("combobox", {
name: "Status: Außerhalb des Bereichs",
})
.element(),
screen.getByRole("combobox", { name: "Kategorie" }).element(),
screen.getByRole("button", { name: "Zurücksetzen" }).element(),
];
for (const el of controls) {
const rect = el.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(railRect.left - 1);
expect(rect.right).toBeLessThanOrEqual(railRect.right + 1);
}
});
it("aligns search and filter controls in one row on wide screens", async () => {
await page.viewport(2560, 900);
const { screen } = await renderWithApp(<LabFilterRail {...props()} />);
const controls = [
screen.getByRole("searchbox", { name: "Search biomarkers" }).element(),
screen.getByRole("button", { name: "Last measured" }).element(),
screen.getByRole("combobox", { name: "Status" }).element(),
screen.getByRole("combobox", { name: "Category" }).element(),
];
const centers = controls.map(centerY);
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThan(2);
});
});
/** The rail wired to the real filter hook, with a dialog to open over it. */
function WiredRail() {
const lab = useLabListFilters();
const [dialog, setDialog] = useState(false);
return (
<>
<LabFilterRail
{...props()}
filters={lab.filters}
searchDraft={lab.searchDraft}
onSearchChange={lab.setSearchDraft}
searchInputRef={lab.searchInputRef}
/>
<button type="button" onClick={() => setDialog(true)}>
Open dialog
</button>
{dialog ? (
<div role="dialog" aria-label="Sheet">
<button type="button" autoFocus>
Inside
</button>
</div>
) : null}
</>
);
}
describe("<LabFilterRail> keyboard", () => {
it("focuses the search field on / and keeps focus there after clearing", async () => {
const { screen } = await renderWithApp(<WiredRail />);
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
(
screen
.getByRole("button", { name: "Open dialog" })
.element() as HTMLElement
).focus();
await userEvent.keyboard("/");
await expect.element(search).toHaveFocus();
await expect.element(search).toHaveValue("");
await userEvent.keyboard("iron");
await screen.getByRole("button", { name: "Clear search" }).click();
await expect.element(search).toHaveValue("");
await expect.element(search).toHaveFocus();
});
it("leaves / alone while focus is inside an open dialog", async () => {
const { screen } = await renderWithApp(<WiredRail />);
await screen.getByRole("button", { name: "Open dialog" }).click();
const inside = screen.getByRole("button", { name: "Inside" });
await expect.element(inside).toHaveFocus();
await userEvent.keyboard("/");
await expect.element(inside).toHaveFocus();
});
});
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { I18nProvider } from "@/lib/i18n/context";
import { LabFilterRail } from "../lab-filter-rail";
primeLocales("en", "pl", "ru");
describe("<LabFilterRail>", () => {
it("renders search input, shortcut hint, and filter pills in canonical order", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={{}}
searchDraft=""
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
categories={[
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
{
key: "cat_lipids",
label: "Lipids",
markerKeys: ["bm:b2"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
]}
total={5}
/>
</I18nProvider>,
);
expect(html).toContain('data-slot="lab-filter-rail"');
expect(html).toContain('placeholder="Search biomarkers…"');
expect(html).toContain('aria-keyshortcuts="/"');
expect(html).toMatch(/<kbd[^>]*aria-hidden="true"[^>]*>\s*\/\s*<\/kbd>/);
// The shared rail keeps search and all filter dimensions in one ordered row.
const searchIdx = html.indexOf('placeholder="Search biomarkers…"');
const dateRangeIdx = html.indexOf("Last measured");
const statusIdx = html.indexOf("Status");
const categoryIdx = html.indexOf("Category");
expect(searchIdx).toBeGreaterThan(-1);
expect(dateRangeIdx).toBeGreaterThan(searchIdx);
expect(statusIdx).toBeGreaterThan(dateRangeIdx);
expect(categoryIdx).toBeGreaterThan(statusIdx);
// The saved sort in Settings > Labs is the only sort; the rail has none.
expect(html).not.toContain("Sort order");
expect(html).toContain("5 biomarkers");
});
it("shows search clear button with accessible label and hides kbd shortcut when search has text", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={{ q: "ferritin" }}
searchDraft="ferritin"
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={1}
/>
</I18nProvider>,
);
expect(html).toContain('aria-label="Clear search"');
expect(html).not.toContain("<kbd");
});
it("pluralizes marker count with tCount across locales", () => {
const extractCountText = (html: string) => {
const match = html.match(/<span[^>]*tabular-nums[^>]*>([^<]+)<\/span>/);
return match ? match[1].trim() : null;
};
const renderCount = (locale: "en" | "pl" | "ru", total: number) =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabFilterRail
filters={{}}
searchDraft=""
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={false}
total={total}
/>
</I18nProvider>,
);
expect(extractCountText(renderCount("en", 1))).toBe("1 biomarker");
expect(extractCountText(renderCount("en", 2))).toBe("2 biomarkers");
expect(extractCountText(renderCount("pl", 1))).toBe("1 biomarker");
expect(extractCountText(renderCount("pl", 2))).toBe("2 biomarkery");
expect(extractCountText(renderCount("pl", 5))).toBe("5 biomarkerów");
expect(extractCountText(renderCount("ru", 1))).toBe("1 биомаркер");
expect(extractCountText(renderCount("ru", 2))).toBe("2 биомаркера");
expect(extractCountText(renderCount("ru", 5))).toBe("5 биомаркеров");
});
it("announces the result count as it changes while typing", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={{}}
searchDraft="ferr"
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered
total={2}
/>
</I18nProvider>,
);
const count = html.match(/<span([^>]*)>2 biomarkers<\/span>/);
expect(count).not.toBeNull();
expect(count![1]).toContain('role="status"');
expect(count![1]).toContain('aria-live="polite"');
expect(count![1]).toContain('aria-atomic="true"');
});
it("shows reset button when isFiltered is true", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={{ status: "abnormal" }}
searchDraft=""
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={true}
total={2}
/>
</I18nProvider>,
);
expect(html).toContain("Reset");
});
it("offers the category pill only when there is more than one category", () => {
const one = [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range" as const,
},
];
const render = (category?: string) =>
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LabFilterRail
filters={category ? { category } : {}}
searchDraft=""
onSearchChange={vi.fn()}
searchInputRef={{ current: null }}
onStatusChange={vi.fn()}
onCategoryChange={vi.fn()}
onFromDayChange={vi.fn()}
onToDayChange={vi.fn()}
onResetFilters={vi.fn()}
isFiltered={Boolean(category)}
categories={one}
total={1}
/>
</I18nProvider>,
);
expect(render()).not.toContain("Category");
// A link that already filters by the one category keeps its pill, so
// the filter can be cleared where it shows.
expect(render("cat_iron")).toContain("Category: Iron Panel");
});
});
@@ -47,16 +47,17 @@ function reading(over: Partial<LabResultDto>): LabResultDto {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
printedReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in_range",
rangeStatus: "in-range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
} as LabResultDto;
};
}
function renderList(
@@ -0,0 +1,375 @@
import { describe, expect, it } from "vitest";
import {
buildMarkerCategoryMap,
filterMarkerRows,
labListFiltersToSearch,
labReadingDayKey,
parseLabListSearchParams,
sortMarkerRows,
type LabListFilters,
} from "../lab-list-filters";
import type { MarkerRow } from "../lab-list-rows";
import type { LabOverviewCategoryDto, LabResultDto } from "../types";
function mockReading(over: Partial<LabResultDto>): LabResultDto {
return {
id: "r1",
biomarkerId: "b1",
panel: null,
analyte: "Ferritin",
value: 80,
valueText: null,
unit: "ng/mL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
catalogReferenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
printedReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
};
}
function mockMarkerRow(over: Partial<MarkerRow>): MarkerRow {
return {
key: "bm:1",
biomarkerId: "1",
analyte: "Ferritin",
panel: "Iron Studies",
unit: "ng/mL",
count: 2,
trend: [70, 80],
latest: mockReading({
biomarkerId: "1",
analyte: "Ferritin",
panel: "Iron Studies",
rangeStatus: "in-range",
takenAt: "2026-09-01T12:00:00.000Z",
}),
...over,
};
}
describe("parseLabListSearchParams & labListFiltersToSearch", () => {
it("parses empty search params into empty filters", () => {
expect(parseLabListSearchParams(new URLSearchParams())).toEqual({});
});
it("parses valid facets", () => {
const params = new URLSearchParams(
"q=iron&status=abnormal&category=cat_iron&from=2026-01-01&to=2026-12-31",
);
expect(parseLabListSearchParams(params)).toEqual<LabListFilters>({
q: "iron",
status: "abnormal",
category: "cat_iron",
fromDay: "2026-01-01",
toDay: "2026-12-31",
});
});
it("drops invalid status and invalid dates", () => {
const params = new URLSearchParams(
"status=invalid&category=ALL&from=not-a-date&to=invalid",
);
expect(parseLabListSearchParams(params)).toEqual({});
});
it("reads reversed date bounds as the range they mean", () => {
expect(
parseLabListSearchParams(
new URLSearchParams("from=2026-09-30&to=2026-09-01"),
),
).toEqual({ fromDay: "2026-09-01", toDay: "2026-09-30" });
});
it("rejects non-existent calendar dates like 2026-02-30", () => {
const params = new URLSearchParams("from=2026-02-30&to=2026-04-31");
expect(parseLabListSearchParams(params)).toEqual({});
});
it("parses category param and ignores category=ALL", () => {
expect(
parseLabListSearchParams(new URLSearchParams("category=cat_valid")),
).toEqual({
category: "cat_valid",
});
expect(
parseLabListSearchParams(new URLSearchParams("category=ALL")),
).toEqual({});
});
it("ignores a sort param: the list follows the saved sort only", () => {
expect(
parseLabListSearchParams(new URLSearchParams("sort=alphaAsc")),
).toEqual({});
});
it("roundtrips valid filters to search string", () => {
const filters: LabListFilters = {
q: "cholesterol",
status: "above",
category: "lipids",
fromDay: "2026-05-01",
toDay: "2026-05-31",
};
const search = labListFiltersToSearch(filters);
const parsed = parseLabListSearchParams(new URLSearchParams(search));
expect(parsed).toEqual(filters);
});
});
describe("filterMarkerRows", () => {
const row1 = mockMarkerRow({
key: "bm:1",
analyte: "Ferritin",
panel: "Iron Studies",
latest: mockReading({
rangeStatus: "in-range",
takenAt: "2026-08-01T10:00:00.000Z",
}),
});
const row2 = mockMarkerRow({
key: "bm:2",
analyte: "Iron",
panel: "Iron Studies",
latest: mockReading({
rangeStatus: "below",
takenAt: "2026-07-15T10:00:00.000Z",
}),
});
const row3 = mockMarkerRow({
key: "bm:3",
analyte: "Total Cholesterol",
panel: "Lipid Panel",
latest: mockReading({
rangeStatus: "above",
takenAt: "2026-09-10T10:00:00.000Z",
}),
});
const rows = [row1, row2, row3];
it("filters by query matching analyte or panel", () => {
expect(filterMarkerRows(rows, { q: "cholest" })).toEqual([row3]);
expect(filterMarkerRows(rows, { q: "Iron" })).toEqual([row1, row2]);
});
it("matches the search regardless of case and accents", () => {
const accented = mockMarkerRow({
key: "bm:hb",
analyte: "Hämoglobin",
panel: "Blutbild",
});
expect(filterMarkerRows([accented], { q: "HAMOGLOBIN" })).toEqual([
accented,
]);
expect(filterMarkerRows([accented], { q: "hämo" })).toEqual([accented]);
expect(filterMarkerRows([accented], { q: "blutbild" })).toEqual([accented]);
expect(filterMarkerRows([accented], { q: "ferritin" })).toEqual([]);
});
it("filters by status: abnormal (below or above)", () => {
expect(filterMarkerRows(rows, { status: "abnormal" })).toEqual([
row2,
row3,
]);
});
it("filters by status: in-range", () => {
expect(filterMarkerRows(rows, { status: "in-range" })).toEqual([row1]);
});
it("filters by status: below", () => {
expect(filterMarkerRows(rows, { status: "below" })).toEqual([row2]);
});
it("filters by category using categoryOf lookup", () => {
const categoryOf = (key: string) =>
key === "bm:1" || key === "bm:2" ? "iron_panel" : "lipid_panel";
expect(
filterMarkerRows(rows, { category: "lipid_panel" }, categoryOf),
).toEqual([row3]);
});
it("fails closed when filtering by category without categoryOf", () => {
expect(filterMarkerRows(rows, { category: "iron_panel" })).toEqual([]);
});
it("filters by date bounds on latest takenAt", () => {
expect(filterMarkerRows(rows, { fromDay: "2026-08-01" })).toEqual([
row1,
row3,
]);
expect(filterMarkerRows(rows, { toDay: "2026-07-31" })).toEqual([row2]);
expect(
filterMarkerRows(rows, {
fromDay: "2026-07-01",
toDay: "2026-08-15",
}),
).toEqual([row1, row2]);
});
it("matches local day bounds using labReadingDayKey (handling timezone shifts)", () => {
// 2026-08-31T22:30:00.000Z in UTC is 2026-08-31.
// In Europe/Warsaw (UTC+2 in summer / CEST), it is 2026-09-01 00:30.
// In America/New_York (UTC-4 in summer / EDT), it is 2026-08-31 18:30.
const lateRow = mockMarkerRow({
key: "bm:late",
latest: mockReading({
takenAt: "2026-08-31T22:30:00.000Z",
}),
});
const dayKeyWarsaw = (takenAt: string) =>
labReadingDayKey(takenAt, "Europe/Warsaw");
const dayKeyNy = (takenAt: string) =>
labReadingDayKey(takenAt, "America/New_York");
// Warsaw (local date 2026-09-01): matches from 2026-09-01, excluded by to 2026-08-31
expect(
filterMarkerRows(
[lateRow],
{ fromDay: "2026-09-01" },
undefined,
dayKeyWarsaw,
),
).toEqual([lateRow]);
expect(
filterMarkerRows(
[lateRow],
{ toDay: "2026-08-31" },
undefined,
dayKeyWarsaw,
),
).toEqual([]);
// New York (local date 2026-08-31): excluded by from 2026-09-01, matches to 2026-08-31
expect(
filterMarkerRows(
[lateRow],
{ fromDay: "2026-09-01" },
undefined,
dayKeyNy,
),
).toEqual([]);
expect(
filterMarkerRows([lateRow], { toDay: "2026-08-31" }, undefined, dayKeyNy),
).toEqual([lateRow]);
});
});
describe("sortMarkerRows", () => {
const rowA = mockMarkerRow({ key: "bm:1", biomarkerId: "1", analyte: "B12" });
const rowB = mockMarkerRow({ key: "bm:2", biomarkerId: "2", analyte: "CRP" });
const rowC = mockMarkerRow({
key: "bm:3",
biomarkerId: "3",
analyte: "Alanine",
});
const rows = [rowA, rowB, rowC];
it("sorts alphaAsc", () => {
const sorted = sortMarkerRows(rows, "alphaAsc");
expect(sorted.map((r) => r.analyte)).toEqual(["Alanine", "B12", "CRP"]);
});
it("sorts alphaDesc", () => {
const sorted = sortMarkerRows(rows, "alphaDesc");
expect(sorted.map((r) => r.analyte)).toEqual(["CRP", "B12", "Alanine"]);
});
it("sorts recentAsc (reverses input)", () => {
const sorted = sortMarkerRows(rows, "recentAsc");
expect(sorted).toEqual([rowC, rowB, rowA]);
});
it("sorts manual", () => {
const sorted = sortMarkerRows(rows, "manual", ["2", "1", "3"]);
expect(sorted.map((r) => r.key)).toEqual(["bm:2", "bm:1", "bm:3"]);
});
it("handles sort=manual without manualOrder gracefully", () => {
expect(sortMarkerRows(rows, "manual", [])).toEqual(rows);
expect(sortMarkerRows(rows, "manual", undefined)).toEqual(rows);
});
});
describe("labReadingDayKey", () => {
it("keeps noon UTC anchors on their stated date across all timezones", () => {
const noonInstant = "2026-09-01T12:00:00.000Z";
expect(labReadingDayKey(noonInstant, "UTC")).toBe("2026-09-01");
expect(labReadingDayKey(noonInstant, "America/New_York")).toBe(
"2026-09-01",
);
expect(labReadingDayKey(noonInstant, "Pacific/Kiritimati")).toBe(
"2026-09-01",
);
});
it("documents exact-noon date-anchor ambiguity vs true instant timestamps", () => {
// Because the data model does not have an explicit `dateOnly` boolean flag,
// exact 12:00:00.000Z is treated as a date anchor by convention to align with
// `useLabDate` / `date-only.ts`. In Pacific/Kiritimati (UTC+14), 12:00 UTC would be
// 02:00 on the NEXT calendar day if treated as an instant; but as an anchor it stays 2026-09-01.
const noonAnchor = "2026-09-01T12:00:00.000Z";
expect(labReadingDayKey(noonAnchor, "Pacific/Kiritimati")).toBe(
"2026-09-01",
);
// Any non-noon instant (even 1 second off noon) is recognized as a true instant with time
// and correctly shifts to the local date in the user's timezone:
const nonNoonInstant = "2026-09-01T12:00:01.000Z";
expect(labReadingDayKey(nonNoonInstant, "Pacific/Kiritimati")).toBe(
"2026-09-02",
);
expect(labReadingDayKey(nonNoonInstant, "UTC")).toBe("2026-09-01");
});
it("formats timestamps with times in the user's timezone", () => {
// 2026-09-01 00:30 UTC+2 (Warsaw) = 2026-08-31 22:30 UTC
const eveningUtc = "2026-08-31T22:30:00.000Z";
expect(labReadingDayKey(eveningUtc, "UTC")).toBe("2026-08-31");
expect(labReadingDayKey(eveningUtc, "Europe/Warsaw")).toBe("2026-09-01");
expect(labReadingDayKey(eveningUtc, "America/New_York")).toBe("2026-08-31");
});
});
describe("buildMarkerCategoryMap", () => {
it("builds map from categories", () => {
const categories: LabOverviewCategoryDto[] = [
{
key: "cat1",
label: "Category 1",
markerKeys: ["bm:1", "bm:2"],
counts: { inRange: 2, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
{
key: "cat2",
label: "Category 2",
markerKeys: ["bm:3"],
counts: { inRange: 0, below: 1, above: 0, unknown: 0 },
status: "below",
},
];
const map = buildMarkerCategoryMap(categories);
expect(map.get("bm:1")).toBe("cat1");
expect(map.get("bm:2")).toBe("cat1");
expect(map.get("bm:3")).toBe("cat2");
expect(map.get("bm:4")).toBeUndefined();
});
});
@@ -0,0 +1,348 @@
import { useEffect, useState } from "react";
import type { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { queryKeys } from "@/lib/query-keys";
import {
renderWithApp,
testQueryClient,
} from "../../../../tests/browser/render";
import {
currentSearch,
recordHistory,
setSearch,
} from "../../../../tests/browser/url";
import { LabList } from "../lab-list";
import { readStoredOpen } from "../lab-list-groups";
import type { LabOverviewResponse, LabResultDto } from "../types";
import { useLabListFilters } from "../use-lab-list-filters";
function mockReading(over: Partial<LabResultDto>): LabResultDto {
return {
id: "r1",
biomarkerId: "b1",
panel: null,
analyte: "Ferritin",
value: 80,
valueText: null,
unit: "ng/mL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
catalogReferenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
printedReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
};
}
const overviewWithCategories: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin" }),
},
],
categories: [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
],
grouped: true,
};
const overviewWithAbnormalMarker: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [10],
latest: mockReading({ analyte: "Iron", rangeStatus: "below" }),
},
{
key: "bm:b2",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin", rangeStatus: "in-range" }),
},
],
categories: [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1", "bm:b2"],
counts: { inRange: 1, below: 1, above: 0, unknown: 0 },
status: "below",
},
],
grouped: true,
};
function labQueryClient(): QueryClient {
const client = testQueryClient();
client.setDefaultOptions({
queries: { retry: false, staleTime: Infinity },
});
return client;
}
function FilterCallbackStabilityProbe({
onRender,
}: {
onRender: (cb: unknown) => void;
}) {
const [, setTick] = useState(0);
const { setCategoryFilter } = useLabListFilters();
useEffect(() => {
onRender(setCategoryFilter);
});
return (
<button type="button" onClick={() => setTick((n) => n + 1)}>
Rerender
</button>
);
}
describe("LabList stale category and group header handling", () => {
it("normalizes URL via replace after paint and does not filter visible rows when category is stale", async () => {
setSearch("?category=stale");
const history = recordHistory();
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
// Stale category is scrubbed from URL after paint using replace (no pushes)
await expect.poll(currentSearch).toBe("");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual([""]);
// Visible filtering ignores the stale category: the marker is visible,
// and the one real category leaves no category pill behind.
await expect.element(screen.getByText("Ferritin")).toBeVisible();
await expect
.element(screen.getByRole("combobox", { name: /^Category/ }))
.not.toBeInTheDocument();
});
it("preserves active non-stale filters and scrubs stale category from URL after paint", async () => {
setSearch("?status=abnormal&category=stale");
const history = recordHistory();
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
await renderWithApp(<LabList />, { queryClient });
await expect.poll(currentSearch).toBe("?status=abnormal");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual(["?status=abnormal"]);
});
it("preserves valid category in URL without triggering replace", async () => {
setSearch("?category=cat_iron");
const history = recordHistory();
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithCategories,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
await expect.poll(currentSearch).toBe("?category=cat_iron");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual([]);
await expect.element(screen.getByText("Ferritin")).toBeVisible();
});
it("reflects filtered row count and outside status in category group header DOM", async () => {
setSearch("?status=below");
const queryClient = labQueryClient();
queryClient.setQueryData(queryKeys.moduleListLayout(), {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "expanded",
categoryOrder: [],
},
},
});
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithAbnormalMarker,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
// Only the 'below' marker (Iron) is visible
await expect.element(screen.getByText("Iron")).toBeVisible();
await expect.element(screen.getByText("Ferritin")).not.toBeInTheDocument();
// Group header button shows the filtered count ("1 of 1 outside range") and chip "1"
const groupButton = screen.getByRole("button", { name: /Iron Panel/i });
await expect.element(groupButton).toBeVisible();
await expect
.element(groupButton.getByText("1 of 1 outside range"))
.toBeVisible();
await expect.element(groupButton.getByText("1")).toBeVisible();
});
it("keeps a flagged group open while a filter leaves only in-range rows", async () => {
// Iron (below) and Ferritin (in-range) share the Iron Panel, whose server
// status is "below". Filtering to in-range leaves Ferritin alone: the
// header now reads "All in range", but the group must stay open, since
// the counter says there is a match.
setSearch("?status=in-range");
const queryClient = labQueryClient();
queryClient.setQueryData(queryKeys.moduleListLayout(), {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "flagged",
categoryOrder: [],
},
},
});
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithAbnormalMarker,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
const groupButton = screen.getByRole("button", { name: /Iron Panel/i });
await expect.element(groupButton.getByText("All in range")).toBeVisible();
await expect.element(groupButton).toHaveAttribute("aria-expanded", "true");
await expect.element(screen.getByText("Ferritin")).toBeVisible();
});
it("opens every matching group while filtering and restores the closed state after reset", async () => {
setSearch("?q=ferr");
window.localStorage.clear();
const queryClient = labQueryClient();
queryClient.setQueryData(queryKeys.moduleListLayout(), {
version: 1,
lists: {
labs: {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "collapsed",
categoryOrder: [],
},
},
});
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithAbnormalMarker,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
const groupButton = screen.getByRole("button", { name: /Iron Panel/i });
await expect.element(groupButton).toHaveAttribute("aria-expanded", "true");
await expect.element(screen.getByText("Ferritin")).toBeVisible();
// A toggle made while filtering holds while the filter changes...
await groupButton.click();
await expect.element(groupButton).toHaveAttribute("aria-expanded", "false");
await screen.getByRole("combobox", { name: "Status" }).click();
await screen.getByRole("option", { name: "In range" }).click();
await expect.poll(currentSearch).toBe("?q=ferr&status=in-range");
await expect.element(groupButton).toHaveAttribute("aria-expanded", "false");
// ...and is dropped with the filter: "collapsed" applies again.
await screen.getByRole("button", { name: "Reset" }).click();
await expect.poll(currentSearch).toBe("");
await expect.element(groupButton).toHaveAttribute("aria-expanded", "false");
expect(readStoredOpen("collapsed")).toEqual({});
});
it("keeps a trailing space in the search field after the URL takes the query", async () => {
setSearch("");
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithAbnormalMarker,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
await search.fill("iron ");
await expect.poll(currentSearch).toBe("?q=iron");
// One more frame for the URL change to reach the hook.
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
await expect.element(search).toHaveValue("iron ");
});
it("takes the query from the URL on back navigation", async () => {
setSearch("?q=iron");
const queryClient = labQueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overviewWithAbnormalMarker,
);
const { screen } = await renderWithApp(<LabList />, { queryClient });
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
await expect.element(search).toHaveValue("iron");
await screen.getByRole("button", { name: "Reset" }).click();
await expect.poll(currentSearch).toBe("");
await expect.element(search).toHaveValue("");
window.history.back();
await expect.poll(currentSearch).toBe("?q=iron");
await expect.element(search).toHaveValue("iron");
});
it("memoizes setCategoryFilter callback identity across re-renders", async () => {
const callbacks: unknown[] = [];
const { screen } = await renderWithApp(
<FilterCallbackStabilityProbe onRender={(cb) => callbacks.push(cb)} />,
);
await screen.getByRole("button", { name: "Rerender" }).click();
expect(callbacks.length).toBeGreaterThanOrEqual(2);
expect(callbacks[0]).toBe(callbacks[1]);
});
});
@@ -0,0 +1,224 @@
/**
* URL-owned filter state on the labs overview list (the documents-vault and
* measurements pattern via `useUrlFilterSync`):
*
* - a filter or sort change writes the serialised facet set to the page URL
* (push for discrete selects, replace for date bounds, bare pathname when
* everything clears);
* - mounting with a query string restores every facet into the filter rail;
* - sort selection overrides the list presentation without altering persisted
* user settings, does not mark `isFiltered` true, and reset restores the
* preference default.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
installUrlStateBrowser,
type UrlStateBrowser,
} from "@/__tests__/helpers/url-state-browser";
import type { ModuleListPrefs } from "@/lib/module-list-prefs";
import type { LabFilterRailProps } from "../lab-filter-rail";
import type { LabOverviewResponse, LabResultDto } from "../types";
import { LabList } from "../lab-list";
let mockSearch = "";
const pushMock = vi.fn();
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
usePathname: () => "/labs",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
let capturedRailProps: LabFilterRailProps | null = null;
vi.mock("../lab-filter-rail", () => ({
LabFilterRail: (props: LabFilterRailProps) => {
capturedRailProps = props;
return <div data-slot="lab-filter-rail" />;
},
}));
const mockModulePrefs: ModuleListPrefs = {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
vi.mock("@/lib/module-list-prefs", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/module-list-prefs")>();
return {
...actual,
useModuleListPrefs: () => ({
prefs: mockModulePrefs,
}),
};
});
function mockReading(over: Partial<LabResultDto>): LabResultDto {
return {
id: "r1",
biomarkerId: "b1",
panel: null,
analyte: "Ferritin",
value: 80,
valueText: null,
unit: "ng/mL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
catalogReferenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
printedReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
};
}
const baseOverview: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin" }),
},
],
categories: [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
],
grouped: false,
};
let browser: UrlStateBrowser = installUrlStateBrowser("/");
afterEach(() => browser.uninstall());
function render(search: string) {
mockSearch = search;
capturedRailProps = null;
browser.uninstall();
browser = installUrlStateBrowser(search ? `/labs?${search}` : "/labs");
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.labResultsOverview("en"), baseOverview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
return html;
}
beforeEach(() => {
pushMock.mockClear();
replaceMock.mockClear();
mockModulePrefs.sortDir = "recentDesc";
mockModulePrefs.order = [];
});
describe("LabList URL filter sync — interactions write to URL", () => {
it("pushes the status facet", () => {
render("");
expect(capturedRailProps).not.toBeNull();
capturedRailProps!.onStatusChange("abnormal");
expect(browser.push).toHaveBeenCalledWith("/labs?status=abnormal");
});
it("pushes the category facet on top of active status", () => {
render("status=abnormal");
capturedRailProps!.onCategoryChange("cat_iron");
expect(browser.push).toHaveBeenCalledWith(
"/labs?status=abnormal&category=cat_iron",
);
});
it("replaces (no new history entry) for date bounds", () => {
render("");
capturedRailProps!.onFromDayChange("2026-09-01");
expect(browser.replace).toHaveBeenCalledWith("/labs?from=2026-09-01");
expect(browser.push).not.toHaveBeenCalled();
});
it("leaves a sort param out of the filter state", () => {
render("sort=alphaAsc");
expect(capturedRailProps!.isFiltered).toBe(false);
capturedRailProps!.onStatusChange("abnormal");
expect(browser.push).toHaveBeenCalledWith("/labs?status=abnormal");
});
it("reset clears every facet in one push", () => {
render("status=abnormal&category=cat_iron&from=2026-09-01");
expect(capturedRailProps!.isFiltered).toBe(true);
capturedRailProps!.onResetFilters();
expect(browser.push).toHaveBeenCalledWith("/labs");
});
});
describe("LabList URL filter sync — mounting restores filters from query", () => {
it("restores every facet into the rail controls", () => {
render(
"q=ferritin&status=in-range&category=cat_iron&from=2026-09-01&to=2026-09-30",
);
expect(capturedRailProps!.filters.q).toBe("ferritin");
expect(capturedRailProps!.filters.status).toBe("in-range");
expect(capturedRailProps!.filters.category).toBe("cat_iron");
expect(capturedRailProps!.filters.fromDay).toBe("2026-09-01");
expect(capturedRailProps!.filters.toDay).toBe("2026-09-30");
expect(capturedRailProps!.isFiltered).toBe(true);
});
it("drops invalid status and invalid dates without breaking the page", () => {
render("status=INVALID&from=2026-02-30&to=not-a-date");
expect(capturedRailProps!.filters.status).toBeUndefined();
expect(capturedRailProps!.filters.fromDay).toBeUndefined();
expect(capturedRailProps!.filters.toDay).toBeUndefined();
expect(capturedRailProps!.isFiltered).toBe(false);
});
it("drops stale category from effective filters when mounting with unknown category", () => {
render("category=unknown_stale_cat");
expect(capturedRailProps!.filters.category).toBeUndefined();
expect(capturedRailProps!.isFiltered).toBe(false);
});
it("replaces URL without pushing history when clearing category with replace mode (no Back-button loop)", () => {
render("category=unknown_stale_cat");
(
capturedRailProps!.onCategoryChange as (
val: string,
mode?: "push" | "replace",
) => void
)("ALL", "replace");
expect(browser.replace).toHaveBeenCalledWith("/labs");
expect(browser.push).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,396 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { ModuleListPrefs } from "@/lib/settings/module-list-layout";
import type { LabOverviewResponse, LabResultDto } from "../types";
import { LabList } from "../lab-list";
let mockSearch = "";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/labs",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
let mockModulePrefs: ModuleListPrefs = {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
const displayZone = vi.hoisted(() => ({ current: "UTC" }));
vi.mock("@/lib/i18n/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/i18n/context")>();
return {
...actual,
useDisplayTimezone: () => displayZone.current,
};
});
vi.mock("@/lib/module-list-prefs", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/module-list-prefs")>();
return {
...actual,
useModuleListPrefs: () => ({
prefs: mockModulePrefs,
}),
};
});
function mockReading(over: Partial<LabResultDto>): LabResultDto {
return {
id: "r1",
biomarkerId: "b1",
panel: null,
analyte: "Ferritin",
value: 80,
valueText: null,
unit: "ng/mL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
catalogReferenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
printedReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in-range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
};
}
describe("<LabList>", () => {
beforeEach(() => {
mockSearch = "";
displayZone.current = "UTC";
mockModulePrefs = {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
});
it("renders genuine empty state when no biomarkers exist in data", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [],
categories: [],
grouped: false,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).not.toContain('data-slot="lab-filter-rail"');
expect(html).toContain("No lab results yet");
});
it("renders filter rail and populated rows when data exists", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin" }),
},
],
categories: [],
grouped: false,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).toContain('data-slot="lab-filter-rail"');
expect(html).toContain("Ferritin");
expect(html).toContain("1 biomarker");
});
it("renders filtered empty state with rail and Reset button when filters match 0 rows", () => {
mockSearch = "q=NonExistentBiomarker";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin" }),
},
],
categories: [],
grouped: false,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).toContain('data-slot="lab-filter-rail"');
expect(html).toContain("No matching biomarkers");
expect(html).toContain("Reset");
});
it("recalculates category header member count and status pill from filtered rows (B5)", () => {
// 3 markers in cat_iron: 1 below, 2 in-range
// When filtering by ?status=below, category should show 1 member and 1 of 1 outside range
mockSearch = "status=below";
mockModulePrefs = {
view: "list",
order: [],
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "expanded",
categoryOrder: [],
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [10],
latest: mockReading({
analyte: "Iron",
rangeStatus: "below",
}),
},
{
key: "bm:b2",
count: 1,
trend: [80],
latest: mockReading({
analyte: "Ferritin",
rangeStatus: "in-range",
}),
},
{
key: "bm:b3",
count: 1,
trend: [250],
latest: mockReading({
analyte: "Transferrin",
rangeStatus: "in-range",
}),
},
],
categories: [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1", "bm:b2", "bm:b3"],
counts: { inRange: 2, below: 1, above: 0, unknown: 0 },
status: "below",
},
],
grouped: true,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
// Only the 'below' marker (Iron) should be in the list
expect(html).toContain("Iron");
expect(html).not.toContain("Ferritin");
expect(html).not.toContain("Transferrin");
// The category header reflects the filtered 1 row (tested further in browser DOM suite)
expect(html).toContain("1 of 1 outside range");
});
it("sorts by the saved preference and ignores a sort param in the URL", () => {
mockSearch = "sort=recentDesc";
mockModulePrefs = {
view: "list",
order: [],
sortDir: "alphaAsc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b2",
count: 1,
trend: [5],
latest: mockReading({ id: "r2", biomarkerId: "b2", analyte: "Zinc" }),
},
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin" }),
},
],
categories: [],
grouped: false,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
expect(html.indexOf("Ferritin")).toBeGreaterThan(-1);
expect(html.indexOf("Zinc")).toBeGreaterThan(html.indexOf("Ferritin"));
expect(html).not.toContain("Sort order");
});
it("filters by local day bounds using display timezone (Europe/Warsaw)", () => {
// 2026-08-31T22:30:00.000Z in UTC is 2026-08-31, but in Europe/Warsaw (UTC+2) it is 2026-09-01 00:30.
displayZone.current = "Europe/Warsaw";
mockSearch = "from=2026-09-01";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({
analyte: "Ferritin",
takenAt: "2026-08-31T22:30:00.000Z",
}),
},
],
categories: [],
grouped: false,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const htmlWarsaw = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
// In Europe/Warsaw, the reading is on 2026-09-01, matching from=2026-09-01
expect(htmlWarsaw).toContain("Ferritin");
expect(htmlWarsaw).toContain("1 biomarker");
// In America/New_York (UTC-4), the reading is 2026-08-31 18:30, excluded by from=2026-09-01
displayZone.current = "America/New_York";
const htmlNy = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
expect(htmlNy).not.toContain("Ferritin");
expect(htmlNy).toContain("No matching biomarkers");
});
it("ignores stale category from URL when not in loaded categories", () => {
mockSearch = "category=stale_unknown_category";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const overview: LabOverviewResponse = {
markers: [
{
key: "bm:b1",
count: 1,
trend: [80],
latest: mockReading({ analyte: "Ferritin" }),
},
],
categories: [
{
key: "cat_iron",
label: "Iron Panel",
markerKeys: ["bm:b1"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
],
grouped: false,
};
queryClient.setQueryData(queryKeys.labResultsOverview("en"), overview);
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
// Stale category is ignored: the marker is not filtered out, and with a
// single category there is no category pill to show it on.
expect(html).toContain("Ferritin");
expect(html).not.toContain('aria-label="Category');
expect(html).not.toContain("Reset");
expect(html).not.toContain("stale_unknown_category");
});
});
@@ -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.",
);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { renderWithApp } from "../../../../tests/browser/render";
import { useVaultFilters } from "../../documents/vault/use-vault-filters";
import { useLabListFilters } from "../use-lab-list-filters";
/**
* "/" focuses a list's search field. While the page has none on screen (still
* loading, an error, nothing recorded yet) the key must stay the browser's,
* such as Firefox's quick find, and not be swallowed for a field that is not
* there.
*/
function LabsWithoutRail() {
useLabListFilters();
return (
<button type="button" autoFocus>
Elsewhere
</button>
);
}
function VaultWithoutSearch() {
useVaultFilters();
return (
<button type="button" autoFocus>
Elsewhere
</button>
);
}
function pressSlash(target: Element): boolean {
const event = new KeyboardEvent("keydown", {
key: "/",
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event.defaultPrevented;
}
describe('"/" with no search field on screen', () => {
it.each([
["labs", LabsWithoutRail],
["documents", VaultWithoutSearch],
])("leaves the key alone on %s", async (_name, View) => {
const { screen } = await renderWithApp(<View />);
const button = screen.getByRole("button", { name: "Elsewhere" }).element();
expect(pressSlash(button)).toBe(false);
});
});
+162
View File
@@ -0,0 +1,162 @@
"use client";
import * as React from "react";
import { Search, X } from "lucide-react";
import {
FilterBar,
FilterBarDateRange,
FilterBarSelect,
type FilterBarSelectOption,
} from "@/components/ui/filter-bar";
import { Input } from "@/components/ui/input";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
isLabStatusFilter,
type LabListFilters,
type LabStatusFilter,
} from "./lab-list-filters";
import type { LabOverviewCategoryDto } from "./types";
export interface LabFilterRailProps {
filters: LabListFilters;
searchDraft: string;
onSearchChange: (value: string) => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
onStatusChange: (status: LabStatusFilter | "ALL") => void;
onCategoryChange: (category: string) => void;
onFromDayChange: (from: string) => void;
onToDayChange: (to: string) => void;
onResetFilters: () => void;
isFiltered: boolean;
categories?: LabOverviewCategoryDto[] | null;
total?: number;
}
export function LabFilterRail({
filters,
searchDraft,
onSearchChange,
searchInputRef,
onStatusChange,
onCategoryChange,
onFromDayChange,
onToDayChange,
onResetFilters,
isFiltered,
categories,
total,
}: LabFilterRailProps) {
const { t, tCount } = useTranslations();
const fmt = useFormatters();
const statusOptions: FilterBarSelectOption[] = [
{ value: "abnormal", label: t("labs.filter.statusAbnormal") },
{ value: "in-range", label: t("labs.range.inRange") },
{ value: "below", label: t("labs.range.below") },
{ value: "above", label: t("labs.range.above") },
];
// The server's categories in the server's order, so the pill follows the
// saved category order. One category is nothing to choose between, unless
// a link already filters by it and the pill is the way to clear it.
const categoryOptions: FilterBarSelectOption[] = (categories ?? []).map(
(c) => ({
value: c.key,
label: c.label,
}),
);
// Search sits beside the rail rather than in it: below `sm` the rail is
// the shared horizontal scroll row, and a full-width input inside it
// would leave the filter icon stranded on a line of its own.
return (
<div
data-slot="lab-filter-rail"
className="flex flex-col gap-3 sm:flex-row sm:items-start"
>
<div className="relative w-full sm:w-56 sm:shrink-0">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref={searchInputRef}
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("labs.filter.searchPlaceholder")}
aria-label={t("labs.filter.searchLabel")}
aria-keyshortcuts="/"
className="w-full min-w-0 pr-11 pl-9 [&::-webkit-search-cancel-button]:appearance-none"
/>
{searchDraft === "" ? (
<kbd
aria-hidden="true"
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
) : (
<button
type="button"
onClick={() => {
onSearchChange("");
// The button goes away with the text; focus stays in the field.
searchInputRef.current?.focus();
}}
aria-label={t("labs.filter.clearSearch")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-0 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] sm:size-10"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
<div className="min-w-0 sm:flex-1">
<FilterBar
isFiltered={isFiltered}
onReset={onResetFilters}
count={
total !== undefined
? tCount("labs.filter.markerCount", total, {
count: fmt.integer(total),
})
: undefined
}
>
<FilterBarDateRange
label={t("labs.filter.lastMeasured")}
from={filters.fromDay ?? ""}
to={filters.toDay ?? ""}
onFromChange={onFromDayChange}
onToChange={onToDayChange}
idPrefix="labs-filter-date"
/>
<FilterBarSelect
label={t("labs.filter.statusLabel")}
value={filters.status ?? "ALL"}
onValueChange={(val) => {
if (isLabStatusFilter(val)) onStatusChange(val);
}}
options={statusOptions}
allValue="ALL"
allLabel={t("labs.filter.statusAll")}
/>
{(categoryOptions.length > 1 || filters.category) && (
<FilterBarSelect
label={t("labs.filter.categoryLabel")}
value={filters.category ?? "ALL"}
onValueChange={onCategoryChange}
options={categoryOptions}
allValue="ALL"
allLabel={t("labs.filter.categoryAll")}
/>
)}
</FilterBar>
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
/**
* Pure URL-facet helpers for the labs overview list, following the
* documents-vault and measurements patterns: the list's filter state
* lives in the page URL (?q&status&category&from&to) so a filtered view is
* deep-linkable and survives navigation, reload and back/forward. Parsing is
* lenient — an unknown facet drops silently — and serialisation omits defaults
* so the unfiltered list keeps a clean URL.
*/
import { applyOrder } from "@/lib/module-list-prefs";
import type { ModuleSortDir } from "@/lib/settings/module-list-layout";
import {
dateOnlyKey,
isCalendarDateKey,
isNoonUtcAnchor,
} from "@/lib/tz/date-only";
import { userDayKey } from "@/lib/tz/format";
import type { MarkerRow } from "./lab-list-rows";
import type { LabOverviewCategoryDto } from "./types";
export const LAB_STATUS_FILTERS = [
"ALL",
"abnormal",
"in-range",
"below",
"above",
] as const;
export type LabStatusFilter = (typeof LAB_STATUS_FILTERS)[number];
export function isLabStatusFilter(value: unknown): value is LabStatusFilter {
return typeof value === "string" && STATUS_SET.has(value);
}
export interface LabListFilters {
/** Case-insensitive substring search over analyte name and panel. */
q?: string;
/** Status filter: ALL | abnormal | in-range | below | above. */
status?: LabStatusFilter;
/** Category key (matches category.key) or ALL. */
category?: string;
/**
* "Last measured": inclusive day bounds (`?from` / `?to`, YYYY-MM-DD) on
* the marker's newest reading (`latest.takenAt`), in the display zone.
* Older readings are not looked at, so a marker measured inside the range
* and again after it drops out, which is why the pill reads "Last measured".
*/
fromDay?: string;
toDay?: string;
}
const STATUS_SET = new Set<string>(LAB_STATUS_FILTERS);
function dayOrUndefined(value: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed && isCalendarDateKey(trimmed) ? trimmed : undefined;
}
/**
* Build a lookup map from markerKey (`bm:<id>` or `analyte:<name>`) to category.key.
*/
export function buildMarkerCategoryMap(
categories: readonly LabOverviewCategoryDto[] | undefined | null,
): Map<string, string> {
const map = new Map<string, string>();
if (!categories) return map;
for (const cat of categories) {
for (const key of cat.markerKeys) {
map.set(key, cat.key);
}
}
return map;
}
/**
* Parse the labs overview URL search params into the filter object.
*/
export function parseLabListSearchParams(
params: URLSearchParams,
): LabListFilters {
const filters: LabListFilters = {};
const q = params.get("q")?.trim();
if (q) filters.q = q;
const status = params.get("status")?.trim();
if (status && isLabStatusFilter(status) && status !== "ALL") {
filters.status = status;
}
const category = params.get("category")?.trim();
if (category && category !== "ALL") {
filters.category = category;
}
const fromDay = dayOrUndefined(params.get("from"));
if (fromDay) filters.fromDay = fromDay;
const toDay = dayOrUndefined(params.get("to"));
if (toDay) filters.toDay = toDay;
// A hand-edited or old link can carry the bounds the wrong way round;
// read it as the range it obviously means rather than as an empty one.
if (filters.fromDay && filters.toDay && filters.fromDay > filters.toDay) {
[filters.fromDay, filters.toDay] = [filters.toDay, filters.fromDay];
}
return filters;
}
/**
* Serialise the filter object back into the page URL query string (no leading `?`).
* Defaults/ALL values are omitted.
*/
export function labListFiltersToSearch(filters: LabListFilters): string {
const sp = new URLSearchParams();
if (filters.q?.trim()) sp.set("q", filters.q.trim());
if (filters.status && filters.status !== "ALL")
sp.set("status", filters.status);
if (filters.category && filters.category !== "ALL") {
sp.set("category", filters.category);
}
if (filters.fromDay) sp.set("from", filters.fromDay);
if (filters.toDay) sp.set("to", filters.toDay);
return sp.toString();
}
/**
* Resolve the local calendar day key (`YYYY-MM-DD`) for a lab reading.
*
* Data model semantics & date-only heuristic:
* The data model (`LabResult` in Prisma and `LabResultDto`) stores `takenAt` as
* an ISO timestamp and does not have an explicit date-only marker column (e.g.
* `hasTime` or `dateOnly`). Instead, the codebase convention (`@/lib/tz/date-only`)
* stores date-only imports at 12:00:00.000 UTC (`dateOnlyAtNoonUtc`).
*
* Inherent ambiguity:
* An instant reading entered at exactly 12:00:00.000Z is indistinguishable from a
* date-only anchor without schema changes. We preserve the `isNoonUtcAnchor`
* heuristic here so that the day key used for filtering matches exactly what the
* row displays to the user via `useLabDate` (`calendarDate(instant)` for noon-UTC
* anchors, avoiding timezone-shift discrepancies in timezones like UTC+14).
* Timestamps with a time of day resolve in the user's timezone via `userDayKey`.
*/
export function labReadingDayKey(takenAt: string, tz: string): string {
const instant = new Date(takenAt);
if (isNoonUtcAnchor(instant)) {
return dateOnlyKey(instant);
}
return userDayKey(instant, tz);
}
/**
* Case- and accent-insensitive form for the search, so "hamoglobin" finds
* "Hämoglobin" and "proteine" finds "Protéine".
*/
function searchFold(value: string): string {
return value.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
}
/**
* Filter rows by text search, reference range status, category, and date bounds.
*/
export function filterMarkerRows(
rows: readonly MarkerRow[],
filters: LabListFilters,
categoryOf?: (markerKey: string) => string | undefined,
dayKeyOf: (takenAt: string) => string = (iso) => iso.slice(0, 10),
): MarkerRow[] {
const trimmed = filters.q?.trim();
const query = trimmed ? searchFold(trimmed) : undefined;
const status = filters.status;
const category = filters.category;
const fromDay = filters.fromDay;
const toDay = filters.toDay;
return rows.filter((row) => {
// 1. Text search
if (query) {
const analyteMatch = searchFold(row.analyte).includes(query);
const panelMatch = row.panel
? searchFold(row.panel).includes(query)
: false;
if (!analyteMatch && !panelMatch) return false;
}
// 2. Reference range status
if (status && status !== "ALL") {
const rowStatus = row.latest.rangeStatus;
if (status === "abnormal") {
if (rowStatus !== "below" && rowStatus !== "above") return false;
} else if (rowStatus !== status) {
return false;
}
}
// 3. Category — fail closed when categoryOf is unavailable
if (category && category !== "ALL") {
if (!categoryOf) return false;
const markerCategory = categoryOf(row.key);
if (markerCategory !== category) return false;
}
// 4. "Last measured": the newest reading's day, never an older one
if (fromDay || toDay) {
const takenDate = dayKeyOf(row.latest.takenAt);
if (fromDay && takenDate < fromDay) return false;
if (toDay && takenDate > toDay) return false;
}
return true;
});
}
/**
* The person's Labs sort (Settings > Labs) over the server's
* most-recent-first order: `recentAsc` reverses, the alpha options sort by
* analyte name (#43), and `manual` applies the persisted biomarker order
* (legacy un-linked rows, which carry no biomarkerId, sort after the ordered
* block). Under categories the same order holds inside each category. The
* list has no sort control of its own; the saved one is the only one.
*/
export function sortMarkerRows(
rows: readonly MarkerRow[],
sortDir: ModuleSortDir,
manualOrder?: readonly string[],
): MarkerRow[] {
if (sortDir === "recentAsc") return [...rows].reverse();
if (sortDir === "alphaAsc" || sortDir === "alphaDesc") {
const dir = sortDir === "alphaAsc" ? 1 : -1;
return [...rows].sort(
(a, b) =>
dir *
a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }),
);
}
if (sortDir === "manual" && manualOrder && manualOrder.length > 0) {
return applyOrder(rows, manualOrder, (row) => row.biomarkerId ?? row.key);
}
return [...rows];
}
+105 -17
View File
@@ -5,9 +5,11 @@
* header itself, and which categories are open.
*
* Everything about a category comes from the server (`categories` on
* `GET /api/labs/overview`): its members, label, order, counts and status.
* This module only places the already-sorted rows under them and builds the
* status sentence from the counts; it never decides a category or a status.
* `GET /api/labs/overview`): its members, label, order, counts and status,
* which describe the full category. When no effective list filters are
* active, this module renders those server category counts and status;
* when filters narrow the visible rows, it summarizes the visible subset
* client-side.
*/
import { useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
@@ -16,6 +18,7 @@ import { ScorePill } from "@/components/ui/score-pill";
import { TagChip } from "@/components/ui/tag-chip";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { priorityReferenceRangeStatus } from "@/lib/labs/reference-range";
import type { ModuleGroupsOpen } from "@/lib/module-list-prefs";
import { cn } from "@/lib/utils";
@@ -102,6 +105,8 @@ function openByDefault(
): boolean {
if (mode === "expanded") return true;
if (mode === "collapsed") return false;
// The server's status for the whole category, never a filtered summary:
// what a filter hides must not open or close a group.
return category.status === "above" || category.status === "below";
}
@@ -110,8 +115,17 @@ function openByDefault(
* category the person opened or closed by hand stays so in this browser
* (keyed by the opaque category key, never by its label) until the choice
* itself changes. Storage is read only after hydration and may be missing.
*
* While a filter is active every category that still has a match is open,
* whatever the choice or the stored toggles say, so a match is never hidden
* behind a closed header. A category opened or closed by hand during
* filtering stays so while the filter changes, is not written to storage,
* and the stored state returns once the filter is cleared.
*/
export function useGroupsOpen(mode: ModuleGroupsOpen): {
export function useGroupsOpen(
mode: ModuleGroupsOpen,
filtering = false,
): {
isOpen: (category: LabOverviewCategoryDto) => boolean;
toggle: (category: LabOverviewCategoryDto) => void;
} {
@@ -123,9 +137,30 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): {
const [toggled, setToggled] = useState<StoredOpen>({ mode, open: {} });
const own = toggled.mode === mode ? toggled.open : {};
const [filtered, setFiltered] = useState<{
filtering: boolean;
open: Record<string, boolean>;
}>({ filtering, open: {} });
if (filtered.filtering !== filtering) {
setFiltered({ filtering, open: {} });
}
const whileFiltering = filtered.filtering === filtering ? filtered.open : {};
const isOpen = (category: LabOverviewCategoryDto) =>
own[category.key] ?? stored[category.key] ?? openByDefault(category, mode);
filtering
? (whileFiltering[category.key] ?? true)
: (own[category.key] ??
stored[category.key] ??
openByDefault(category, mode));
const toggle = (category: LabOverviewCategoryDto) => {
if (filtering) {
setFiltered({
filtering,
open: { ...whileFiltering, [category.key]: !isOpen(category) },
});
return;
}
const next = { ...stored, ...own, [category.key]: !isOpen(category) };
setToggled({ mode, open: next });
writeStoredOpen(mode, next);
@@ -133,18 +168,50 @@ export function useGroupsOpen(mode: ModuleGroupsOpen): {
return { isOpen, toggle };
}
/** The status of a category, from the counts the server sent. */
function GroupStatus({ category }: { category: LabOverviewCategoryDto }) {
export interface LabGroupSummary {
counts: LabOverviewCategoryDto["counts"];
status: LabOverviewCategoryDto["status"];
}
/** Summarise counts and overall status for a list of rows. */
export function summariseGroupRows(
rows: readonly MarkerRow[],
): LabGroupSummary {
const counts = { inRange: 0, below: 0, above: 0, unknown: 0 };
for (const row of rows) {
const s = row.latest.rangeStatus;
if (s === "in-range") counts.inRange += 1;
else if (s === "below") counts.below += 1;
else if (s === "above") counts.above += 1;
else counts.unknown += 1;
}
const status = priorityReferenceRangeStatus(counts);
return { counts, status };
}
/**
* Group-status semantics:
* When filters or search are active, the group summary (both the count badge
* and the range status pill, e.g. "All in range" or "X of Y outside range")
* describes only the visible rows currently matching the filters in this
* group, rather than unfiltered server category totals.
*/
function GroupStatus({
counts,
status,
}: {
counts: LabOverviewCategoryDto["counts"];
status: LabOverviewCategoryDto["status"];
}) {
const { t, tCount } = useTranslations();
if (category.status === "unknown") return null;
if (category.status === "in-range") {
if (status === "unknown") return null;
if (status === "in-range") {
return <ScorePill tone="neutral">{t("labs.groups.allInRange")}</ScorePill>;
}
const { inRange, below, above, unknown } = category.counts;
return (
<ScorePill tone={category.status === "above" ? "warning" : "info"}>
{tCount("labs.groups.outsideCount", above + below, {
total: inRange + below + above + unknown,
<ScorePill tone={status === "above" ? "warning" : "info"}>
{tCount("labs.groups.outsideCount", counts.above + counts.below, {
total: counts.inRange + counts.below + counts.above + counts.unknown,
})}
</ScorePill>
);
@@ -158,17 +225,40 @@ function GroupStatus({ category }: { category: LabOverviewCategoryDto }) {
*/
export function LabGroupHeader({
category,
rows,
open,
onToggle,
panelId,
className,
isFiltered = false,
status: statusProp,
summary: summaryProp,
}: {
category: LabOverviewCategoryDto;
rows: readonly MarkerRow[];
open: boolean;
onToggle: () => void;
panelId: string;
className?: string;
isFiltered?: boolean;
status?: LabOverviewCategoryDto["status"];
summary?: LabGroupSummary;
}) {
const { counts, status } = useMemo(() => {
if (isFiltered) {
const summary = summaryProp ?? summariseGroupRows(rows);
return {
counts: summary.counts,
status: statusProp ?? summary.status,
};
}
const { inRange, below, above, unknown } = category.counts;
return {
counts: { inRange, below, above, unknown },
status: statusProp ?? category.status,
};
}, [category, rows, isFiltered, statusProp, summaryProp]);
return (
<h2 className={cn("min-w-0", className)}>
<button
@@ -190,11 +280,9 @@ export function LabGroupHeader({
<span className="text-muted-foreground min-w-0 truncate text-xs font-medium tracking-wide uppercase">
{category.label}
</span>
<TagChip className="shrink-0 tabular-nums">
{category.markerKeys.length}
</TagChip>
<TagChip className="shrink-0 tabular-nums">{rows.length}</TagChip>
<span className="ml-auto shrink-0">
<GroupStatus category={category} />
<GroupStatus counts={counts} status={status} />
</span>
</button>
</h2>
+275 -92
View File
@@ -1,7 +1,7 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useEffect } from "react";
import { useCallback, useId, useMemo, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
@@ -11,18 +11,26 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import {
applyOrder,
useModuleListPrefs,
type ModuleListPrefs,
type ModuleListView,
} from "@/lib/module-list-prefs";
import { LabFilterRail } from "./lab-filter-rail";
import {
buildMarkerCategoryMap,
filterMarkerRows,
labReadingDayKey,
sortMarkerRows,
} from "./lab-list-filters";
import { useLabListFilters } from "./use-lab-list-filters";
import {
groupMarkerRows,
LabGroupHeader,
summariseGroupRows,
useGroupsOpen,
type MarkerGroup,
} from "./lab-list-groups";
@@ -34,30 +42,7 @@ import {
toMarkerRows,
type MarkerRow,
} from "./lab-list-rows";
import type { LabOverviewResponse } from "./types";
/**
* The person's Labs sort over the server's most-recent-first order:
* `recentAsc` reverses, the alpha options sort by analyte name (#43), and
* `manual` applies the persisted biomarker order (legacy un-linked rows, which
* carry no biomarkerId, sort after the ordered block). Under categories the
* same order holds inside each category.
*/
function sortRows(rows: MarkerRow[], prefs: ModuleListPrefs): MarkerRow[] {
if (prefs.sortDir === "recentAsc") return [...rows].reverse();
if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") {
const dir = prefs.sortDir === "alphaAsc" ? 1 : -1;
return [...rows].sort(
(a, b) =>
dir *
a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }),
);
}
if (prefs.sortDir === "manual") {
return applyOrder(rows, prefs.order, (row) => row.biomarkerId ?? row.key);
}
return rows;
}
import type { LabOverviewCategoryDto, LabOverviewResponse } from "./types";
type GroupsOpen = ReturnType<typeof useGroupsOpen>;
@@ -66,10 +51,12 @@ function LabTable({
rows,
groups,
groupsOpen,
isFiltered,
}: {
rows: MarkerRow[];
groups: MarkerGroup[] | null;
groupsOpen: GroupsOpen;
isFiltered: boolean;
}) {
return (
// `lab-list` marks the read results in both view shapes. The loading
@@ -84,6 +71,7 @@ function LabTable({
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
isFiltered={isFiltered}
/>
))
: rows.map((row) => <LabListRow key={row.key} row={row} />)}
@@ -93,11 +81,14 @@ function LabTable({
);
}
function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) {
function useGroupToggle(
category: LabOverviewCategoryDto,
groupsOpen: GroupsOpen,
) {
const panelId = `${useId()}-panel`;
return {
open: groupsOpen.isOpen(group.category),
onToggle: () => groupsOpen.toggle(group.category),
open: groupsOpen.isOpen(category),
onToggle: () => groupsOpen.toggle(category),
panelId,
};
}
@@ -109,18 +100,32 @@ function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) {
function TableGroup({
group,
groupsOpen,
isFiltered,
}: {
group: MarkerGroup;
groupsOpen: GroupsOpen;
isFiltered: boolean;
}) {
const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen);
const summary = useMemo(
() => (isFiltered ? summariseGroupRows(group.rows) : undefined),
[isFiltered, group.rows],
);
const status = summary?.status ?? group.category.status;
const { open, onToggle, panelId } = useGroupToggle(
group.category,
groupsOpen,
);
return (
<div className={cn(LIST_SUBGRID_CLASS, "divide-y-0")}>
<LabGroupHeader
category={group.category}
rows={group.rows}
open={open}
onToggle={onToggle}
panelId={panelId}
isFiltered={isFiltered}
status={status}
summary={summary}
className="px-4 py-1 lg:col-span-full"
/>
<div
@@ -162,18 +167,32 @@ function CardGrid({
function CardGroup({
group,
groupsOpen,
isFiltered,
}: {
group: MarkerGroup;
groupsOpen: GroupsOpen;
isFiltered: boolean;
}) {
const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen);
const summary = useMemo(
() => (isFiltered ? summariseGroupRows(group.rows) : undefined),
[isFiltered, group.rows],
);
const status = summary?.status ?? group.category.status;
const { open, onToggle, panelId } = useGroupToggle(
group.category,
groupsOpen,
);
return (
<section className="space-y-4">
<LabGroupHeader
category={group.category}
rows={group.rows}
open={open}
onToggle={onToggle}
panelId={panelId}
isFiltered={isFiltered}
status={status}
summary={summary}
/>
<div id={panelId} hidden={!open}>
{open ? <CardGrid rows={group.rows} underCategory /> : null}
@@ -182,6 +201,115 @@ function CardGroup({
);
}
function LabListLoading() {
return (
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
}
function LabListEmpty({
canAddLab,
onAddFirst,
}: {
canAddLab: boolean;
onAddFirst?: () => void;
}) {
const { t } = useTranslations();
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
function LabListNoMatches({ onReset }: { onReset: () => void }) {
const { t } = useTranslations();
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.filter.noMatchesTitle")}
description={t("labs.filter.noMatchesDescription")}
action={
<Button variant="outline" onClick={onReset}>
{t("dataList.resetFilters")}
</Button>
}
/>
);
}
function LabListContent({
view,
rows,
groups,
groupsOpen,
isFiltered,
}: {
view: ModuleListView;
rows: MarkerRow[];
groups: MarkerGroup[] | null;
groupsOpen: GroupsOpen;
isFiltered: boolean;
}) {
// v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 — compact list view is the default (#40); the card/tile view is
// the alternative the settings toggle selects.
if (view === "list") {
return (
<LabTable
rows={rows}
groups={groups}
groupsOpen={groupsOpen}
isFiltered={isFiltered}
/>
);
}
// v1.18.9 (#40) — card/tile view.
if (groups) {
return (
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
isFiltered={isFiltered}
/>
))}
</div>
);
}
return (
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
</div>
);
}
export function LabList({
onAddFirst,
onEmptyChange,
@@ -194,7 +322,7 @@ export function LabList({
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
const groupsOpen = useGroupsOpen(prefs.groupsOpen);
const displayTz = useDisplayTimezone();
// v1.22 — a short, factual line under each marker heading describing what the
// biomarker measures, sourced from the catalog via i18n. Free-text markers
@@ -208,40 +336,97 @@ export function LabList({
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const rows = useMemo(
() => sortRows(toMarkerRows(data?.markers ?? [], t), prefs),
[data?.markers, prefs, t],
const {
filters,
searchDraft,
setSearchDraft,
searchInputRef,
setStatusFilter,
setCategoryFilter,
setFromDay,
setToDay,
resetFilters,
} = useLabListFilters();
const rawRows = useMemo(
() => toMarkerRows(data?.markers ?? [], t),
[data?.markers, t],
);
const categories = data?.categories;
const categoryValid =
!filters.category ||
filters.category === "ALL" ||
!categories ||
categories.some((c) => c.key === filters.category);
const effectiveFilters = useMemo(
() => (categoryValid ? filters : { ...filters, category: undefined }),
[categoryValid, filters],
);
const effectiveIsFiltered = Boolean(
(effectiveFilters.q && effectiveFilters.q.trim() !== "") ||
(effectiveFilters.status && effectiveFilters.status !== "ALL") ||
(effectiveFilters.category && effectiveFilters.category !== "ALL") ||
effectiveFilters.fromDay ||
effectiveFilters.toDay,
);
const groupsOpen = useGroupsOpen(prefs.groupsOpen, effectiveIsFiltered);
// Scrub stale category filter when categories load.
// afterPaint protects mount writes from router history initialization.
useEffect(() => {
if (
categories &&
filters.category &&
!categories.some((c) => c.key === filters.category)
) {
setCategoryFilter("ALL", { mode: "replace", afterPaint: true });
}
}, [categories, filters.category, setCategoryFilter]);
const categoryMap = useMemo(
() => buildMarkerCategoryMap(categories),
[categories],
);
const categoryOf = useCallback(
(key: string) => categoryMap.get(key),
[categoryMap],
);
const dayKeyOf = useCallback(
(takenAt: string) => labReadingDayKey(takenAt, displayTz),
[displayTz],
);
const filteredRows = useMemo(
() => filterMarkerRows(rawRows, effectiveFilters, categoryOf, dayKeyOf),
[rawRows, effectiveFilters, categoryOf, dayKeyOf],
);
const rows = useMemo(
() => sortMarkerRows(filteredRows, prefs.sortDir, prefs.order),
[filteredRows, prefs.sortDir, prefs.order],
);
// Categories only when the person asked for them and the server says they
// add something; otherwise the flat list it always was.
const grouped = prefs.groupBy === "category" && data?.grouped === true;
const categories = data?.categories;
const groups = useMemo(
() => (grouped && categories ? groupMarkerRows(rows, categories) : null),
[grouped, categories, rows],
);
const listEmpty = !isLoading && !isError && rows.length === 0;
const rawEmpty = !isLoading && !isError && rawRows.length === 0;
useEffect(() => {
onEmptyChange?.(listEmpty);
}, [listEmpty, onEmptyChange]);
onEmptyChange?.(rawEmpty);
}, [rawEmpty, onEmptyChange]);
if (isLoading) {
return (
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
return <LabListLoading />;
}
if (isError) {
@@ -256,48 +441,46 @@ export function LabList({
);
}
if (rawRows.length === 0) {
return <LabListEmpty canAddLab={canAddLab} onAddFirst={onAddFirst} />;
}
const filterRail = (
<LabFilterRail
filters={effectiveFilters}
searchDraft={searchDraft}
onSearchChange={setSearchDraft}
searchInputRef={searchInputRef}
onStatusChange={setStatusFilter}
onCategoryChange={setCategoryFilter}
onFromDayChange={setFromDay}
onToDayChange={setToDay}
onResetFilters={resetFilters}
isFiltered={effectiveIsFiltered}
categories={categories}
total={rows.length}
/>
);
if (rows.length === 0) {
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
// v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 — compact list view is the default (#40); the card/tile view is
// the alternative the settings toggle selects.
if (prefs.view === "list") {
return <LabTable rows={rows} groups={groups} groupsOpen={groupsOpen} />;
}
// v1.18.9 (#40) — card/tile view.
if (groups) {
return (
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))}
<div className="space-y-6">
{filterRail}
<LabListNoMatches onReset={resetFilters} />
</div>
);
}
return (
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
<div className="space-y-6">
{filterRail}
<LabListContent
view={prefs.view}
rows={rows}
groups={groups}
groupsOpen={groupsOpen}
isFiltered={effectiveIsFiltered}
/>
</div>
);
}
+77 -5
View File
@@ -5,8 +5,8 @@
*
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers, and whether the list shows its categories and
* which of them start open. Persisted per account through
* order of the biomarkers, and whether the list shows its categories, which
* of them start open and their order. Persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
@@ -19,6 +19,7 @@ import {
ArrowDownUp,
FlaskConical,
FolderTree,
Layers,
LayoutGrid,
ListOrdered,
ScanText,
@@ -27,6 +28,7 @@ import {
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Segmented } from "@/components/ui/segmented";
@@ -47,16 +49,28 @@ import {
type ModuleGroupsOpen,
type ModuleSortDir,
} from "@/lib/module-list-prefs";
import { mergeCategoryOrder } from "@/lib/settings/module-list-layout";
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabOverviewResponse,
} from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } =
useModuleListPrefs("labs");
const {
prefs,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
setCategoryOrder,
} = useModuleListPrefs("labs");
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -120,6 +134,14 @@ export function LabsSettings() {
onGroupsOpen={setGroupsOpen}
/>
{prefs.groupBy === "category" ? (
<LabsCategoryOrderCard
onChange={(shown) =>
setCategoryOrder(mergeCategoryOrder(shown, prefs.categoryOrder))
}
/>
) : null}
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
@@ -204,6 +226,56 @@ function LabsGroupingCard({
);
}
/**
* The order of the categories. The server answers them already in the
* person's order (`GET /api/labs/overview` applies `categoryOrder`), so the
* editor lists them as given, under the labels the overview carries, and
* sends the whole order after every move.
*/
function LabsCategoryOrderCard({
onChange,
}: {
onChange: (categoryOrder: string[]) => void;
}) {
const { t, locale } = useTranslations();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.labResultsOverview(locale),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const items: ReorderItem[] = (data?.categories ?? []).map((category) => ({
id: category.key,
name: category.label,
}));
return (
<SettingsCard id="labs-category-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Layers}
title={t("labs.groups.orderHeading")}
description={t("labs.groups.orderDefault", {
other: t("labs.groups.other"),
})}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
<QueryErrorRow
error={error}
inset
slot="labs-category-order-error"
onRetry={() => void refetch()}
/>
) : (
<ModuleOrderEditor items={items} onChange={onChange} />
)}
</SettingsCard>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
+2 -1
View File
@@ -1,3 +1,4 @@
import type { ReferenceRangeStatusCounts } from "@/lib/labs/reference-range";
import type { RelatedLink } from "@/lib/links/related";
import type {
@@ -87,7 +88,7 @@ export interface LabOverviewCategoryDto {
label: string;
/** The `markers[].key` values in this category. */
markerKeys: string[];
counts: { inRange: number; below: number; above: number; unknown: number };
counts: ReferenceRangeStatusCounts;
status: LabResultDto["rangeStatus"];
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useUrlFilterSync,
type UrlFilterSyncOptions,
} from "@/hooks/use-url-filter-sync";
import {
labListFiltersToSearch,
parseLabListSearchParams,
type LabListFilters,
type LabStatusFilter,
} from "./lab-list-filters";
export function useLabListFilters() {
const { filters, applyFilters, patchFilters } =
useUrlFilterSync<LabListFilters>({
parse: parseLabListSearchParams,
serialise: labListFiltersToSearch,
});
// The field edits a draft; the URL takes it, trimmed, 200 ms after the
// last keystroke. A `q` the URL gains from elsewhere (back/forward, a
// shared link, Reset) replaces the draft. One the draft wrote itself
// does not: adopting it would drop a trailing space ("vitamin " became
// "vitamin", and the next letter ran on), or a letter typed while the
// write was landing.
const urlQ = filters.q ?? "";
const [searchDraft, setSearchDraft] = useState(urlQ);
const [lastUrlQ, setLastUrlQ] = useState(urlQ);
const [ownWrite, setOwnWrite] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
if (urlQ !== lastUrlQ) {
setLastUrlQ(urlQ);
if (urlQ === ownWrite) setOwnWrite(null);
else if (urlQ !== searchDraft.trim()) setSearchDraft(urlQ);
}
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
setOwnWrite(trimmed);
applyFilters(
(current) =>
trimmed === (current.q ?? "")
? current
: { ...current, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
return () => clearTimeout(handle);
}, [searchDraft, filters.q, applyFilters]);
// "/" focuses the search field, unless the key is meant for something
// else: a field being typed in, or an open dialog, menu or list whose
// focus must not jump to the page behind it.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "/" ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
event.defaultPrevented ||
event.isComposing
) {
return;
}
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest(
'[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]',
))
) {
return;
}
// No field on screen (loading, an error, nothing recorded yet): the
// key stays the browser's.
const input = searchInputRef.current;
if (!input || !input.isConnected) return;
event.preventDefault();
input.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const setStatusFilter = useCallback(
(value: LabStatusFilter | "ALL") => {
patchFilters({
status: value === "ALL" ? undefined : value,
});
},
[patchFilters],
);
const setCategoryFilter = useCallback(
(
value: string,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
patchFilters(
{
category: value === "ALL" ? undefined : value,
},
modeOrOptions,
);
},
[patchFilters],
);
const setFromDay = useCallback(
(value: string) => {
patchFilters({ fromDay: value || undefined }, "replace");
},
[patchFilters],
);
const setToDay = useCallback(
(value: string) => {
patchFilters({ toDay: value || undefined }, "replace");
},
[patchFilters],
);
const resetFilters = useCallback(() => {
setSearchDraft("");
applyFilters({});
}, [applyFilters]);
return {
filters,
searchDraft,
setSearchDraft,
searchInputRef,
setStatusFilter,
setCategoryFilter,
setFromDay,
setToDay,
resetFilters,
};
}
@@ -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",
+8 -1
View File
@@ -120,8 +120,15 @@ export function FilterBar({
</Button>
)}
</div>
{/* A polite status, so a list that filters while you type says how
many results are left, not only when none are. */}
{count != null && (
<span className="text-muted-foreground text-sm tabular-nums">
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="text-muted-foreground text-sm tabular-nums"
>
{count}
</span>
)}
@@ -109,6 +109,14 @@ function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
options={SOURCES}
/>
</FilterBar>
<button
type="button"
onClick={() =>
patchFilters({ type: "PULSE" }, { mode: "replace", afterPaint: true })
}
>
Defer replace type
</button>
</>
);
}
@@ -193,6 +201,27 @@ describe("useUrlFilterSync with FilterBar", () => {
expect(window.history.length).toBe(entries);
});
it("defers writing until after paint and replaces current entry without adding history entries", async () => {
const { screen, history } = await mount("?type=WEIGHT");
const entries = window.history.length;
(
screen
.getByRole("button", { name: "Defer replace type" })
.element() as HTMLButtonElement
).click();
// Immediately after trigger, the write is deferred until after paint:
// search remains unchanged and no history write has executed yet.
expect(history.writes).toEqual([]);
expect(currentSearch()).toBe("?type=WEIGHT");
// Once painted frames flush, the replace lands without extra history entry:
await expectSearch("?type=PULSE");
expect(history.pushes()).toEqual([]);
expect(history.replaces()).toEqual(["?type=PULSE"]);
expect(window.history.length).toBe(entries);
});
it("drops one facet with its clear button and keeps the others", async () => {
const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
await screen.getByRole("button", { name: "Clear filter: Type" }).click();
+25 -6
View File
@@ -4,6 +4,13 @@ import { useCallback, useMemo, useState } from "react";
import { setAllParams, useUrlSearch, writeUrlState } from "@/lib/url-state";
export interface UrlFilterSyncOptions {
/** A new history entry (`push`) or the current one rewritten (`replace`). */
mode?: "push" | "replace";
/** Wait two painted frames before writing (a write made while mounting). */
afterPaint?: boolean;
}
/**
* URL-owned filter state — the documents-vault pattern
* (`src/components/documents/documents-view.tsx`), extracted so the
@@ -44,10 +51,13 @@ export function useUrlFilterSync<T>({
filters: T;
applyFilters: (
next: T | ((current: T) => T),
mode?: "push" | "replace",
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
/** Merge `patch` into the live filters (the common facet change). */
patchFilters: (patch: Partial<T>, mode?: "push" | "replace") => void;
patchFilters: (
patch: Partial<T>,
modeOrOptions?: "push" | "replace" | UrlFilterSyncOptions,
) => void;
} {
const params = useUrlSearch();
@@ -60,7 +70,14 @@ export function useUrlFilterSync<T>({
);
const applyFilters = useCallback(
(next: T | ((current: T) => T), mode: "push" | "replace" = "push") => {
(
next: T | ((current: T) => T),
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => {
const options: UrlFilterSyncOptions =
typeof modeOrOptions === "string"
? { mode: modeOrOptions }
: modeOrOptions;
const resolve = (current: T): T =>
typeof next === "function" ? (next as (c: T) => T)(current) : next;
if (!syncToUrl) {
@@ -72,15 +89,17 @@ export function useUrlFilterSync<T>({
const search = serialise(resolve(parse(live)));
setAllParams(search)(live);
},
{ mode },
{ mode: options.mode ?? "push", afterPaint: options.afterPaint },
);
},
[syncToUrl, serialise, parse],
);
const patchFilters = useCallback(
(patch: Partial<T>, mode: "push" | "replace" = "push") =>
applyFilters((current) => ({ ...current, ...patch }), mode),
(
patch: Partial<T>,
modeOrOptions: "push" | "replace" | UrlFilterSyncOptions = "push",
) => applyFilters((current) => ({ ...current, ...patch }), modeOrOptions),
[applyFilters],
);
@@ -131,10 +131,12 @@ const EXPECTED_TYPES = [
"ACUTE_TRAINING_LOAD",
// ── resting energy (#1340) ──
"BASAL_ENERGY_BURNED",
// ── blood ketones (#1367) ──
"BLOOD_KETONES",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 91 canonical measurement types", () => {
it("exposes the 92 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -68,6 +68,7 @@ describe("parseModuleListPrefs", () => {
order: ["a", "b"],
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
});
});
@@ -204,6 +205,85 @@ describe("runSetModuleListPrefs", () => {
});
});
describe("runSetModuleListPrefs category order", () => {
const t = (key: string) => key;
const overview = (keys: string[]) => ({
markers: [],
grouped: true,
categories: keys.map((key) => ({ key, label: key })),
});
it("moves the cached overview's categories at once and refetches it", async () => {
const queryClient = new QueryClient();
const cell = queryKeys.labResultsOverview("en");
queryClient.setQueryData(cell, overview(["lipids", "thyroid", "other"]));
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
let cachedDuringWrite: unknown;
vi.mocked(apiPut).mockImplementation(async () => {
cachedDuringWrite = queryClient.getQueryData(cell);
return resolveModuleListLayout({
labs: { categoryOrder: ["thyroid", "other", "lipids"] },
});
});
await runSetModuleListPrefs({
list: "labs",
patch: { categoryOrder: ["thyroid", "other", "lipids"] },
queryClient,
t,
});
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
version: 1,
list: "labs",
categoryOrder: ["thyroid", "other", "lipids"],
});
expect(
(cachedDuringWrite as ReturnType<typeof overview>).categories.map(
(c) => c.key,
),
).toEqual(["thyroid", "other", "lipids"]);
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.labResultsOverview(),
});
// An order move is its own feedback, like the marker order.
expect(toastWrittenOutcome).not.toHaveBeenCalled();
});
it("refetches the overview after a failed write too, so the server order returns", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overview(["lipids", "thyroid"]),
);
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
await runSetModuleListPrefs({
list: "labs",
patch: { categoryOrder: ["thyroid", "lipids"] },
queryClient,
t,
});
expect(showErrorToast).toHaveBeenCalled();
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.labResultsOverview(),
});
});
it("leaves the overview alone for any other field", async () => {
const queryClient = new QueryClient();
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
vi.mocked(apiPut).mockResolvedValue(resolveModuleListLayout(null));
await runSetModuleListPrefs({
list: "labs",
patch: { order: ["m1"] },
queryClient,
t,
});
expect(invalidate).not.toHaveBeenCalled();
});
});
describe("applyOrder", () => {
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
const idOf = (i: { id: string }) => i.id;
+145
View File
@@ -0,0 +1,145 @@
/**
* #1350 — how a forecast period is drawn in each style. Dates only: the
* projection is handed in, so every expected day below is the arithmetic of
* one fixed cycle and nothing else.
*/
import { describe, expect, it } from "vitest";
import type { ProjectedCycle } from "@/lib/cycle/projection";
import { eventUid } from "@/lib/cycle/ics";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
DISCREET_MARGIN_SUMMARY,
DISCREET_SUMMARY,
buildFeedCalendar,
type FeedForecastStyle,
type FeedTitleMode,
} from "../events";
/** Most likely start 17 Nov, five bleeding days, ±2 days. */
const CYCLE: ProjectedCycle = {
k: 1,
start: "2026-11-17",
periodLength: 5,
bandStart: "2026-11-15",
bandEnd: "2026-11-19",
};
function events(
forecastStyle: FeedForecastStyle,
projected: ProjectedCycle[] = [CYCLE],
titleMode: FeedTitleMode = "DISCREET",
locale: "en" | "de" = "en",
) {
return buildFeedCalendar({
feedId: "feed-1",
titleMode,
forecastStyle,
projected,
history: [],
fertile: null,
translator: getServerTranslator(locale),
}).events;
}
/** start .. endExclusive, the pair DTSTART / DTEND carry. */
const span = (e: { start: string; endExclusive: string }) => [
e.start,
e.endExclusive,
];
describe("forecast styles", () => {
it("BAND draws one event from the earliest start to the latest period's end", () => {
const out = events("BAND");
expect(out.map(span)).toEqual([["2026-11-15", "2026-11-24"]]);
expect(out[0].summary).toBe(DISCREET_SUMMARY);
});
it("EXACT draws only the calculated period", () => {
const out = events("EXACT");
expect(out.map(span)).toEqual([["2026-11-17", "2026-11-22"]]);
expect(out[0].summary).toBe(DISCREET_SUMMARY);
});
it("MARGIN draws the calculated period with the possible days on each side", () => {
const out = events("MARGIN");
expect(out.map(span)).toEqual([
["2026-11-15", "2026-11-17"],
["2026-11-17", "2026-11-22"],
["2026-11-22", "2026-11-24"],
]);
expect(out.map((e) => e.summary)).toEqual([
DISCREET_MARGIN_SUMMARY,
DISCREET_SUMMARY,
DISCREET_MARGIN_SUMMARY,
]);
});
it("MARGIN covers exactly the days BAND covers, without overlap", () => {
const [band] = events("BAND");
const margin = events("MARGIN");
expect(margin[0].start).toBe(band.start);
expect(margin.at(-1)!.endExclusive).toBe(band.endExclusive);
for (let i = 1; i < margin.length; i++) {
expect(margin[i].start).toBe(margin[i - 1].endExclusive);
}
});
it("MARGIN draws no side event where the band is empty", () => {
const exact = { ...CYCLE, bandStart: CYCLE.start, bandEnd: CYCLE.start };
expect(events("MARGIN", [exact]).map(span)).toEqual([
["2026-11-17", "2026-11-22"],
]);
const lateOnly = { ...CYCLE, bandStart: CYCLE.start };
expect(events("MARGIN", [lateOnly]).map(span)).toEqual([
["2026-11-17", "2026-11-22"],
["2026-11-22", "2026-11-24"],
]);
const earlyOnly = { ...CYCLE, bandEnd: CYCLE.start };
expect(events("MARGIN", [earlyOnly]).map(span)).toEqual([
["2026-11-15", "2026-11-17"],
["2026-11-17", "2026-11-22"],
]);
});
it("keeps the period's UID across styles and gives each margin its own", () => {
const main = eventUid("feed-1", "pred:1");
expect(events("BAND")[0].uid).toBe(main);
expect(events("EXACT")[0].uid).toBe(main);
expect(events("MARGIN").map((e) => e.uid)).toEqual([
eventUid("feed-1", "pred:1:before"),
main,
eventUid("feed-1", "pred:1:after"),
]);
});
it("draws every forecast cycle it is given", () => {
const next = {
k: 2,
start: "2026-12-15",
periodLength: 5,
bandStart: "2026-12-12",
bandEnd: "2026-12-18",
};
expect(events("EXACT", [CYCLE, next])).toHaveLength(2);
expect(events("MARGIN", [CYCLE, next])).toHaveLength(6);
expect(events("BAND", [CYCLE, next])).toHaveLength(2);
});
it("names the possible days in the record's language when descriptive", () => {
expect(
events("MARGIN", [CYCLE], "DESCRIPTIVE").map((e) => e.summary),
).toEqual(["Period possible", "Period (estimate)", "Period possible"]);
expect(
events("MARGIN", [CYCLE], "DESCRIPTIVE", "de").map((e) => e.summary),
).toEqual(["Periode möglich", "Periode (Schätzung)", "Periode möglich"]);
});
it("describes every forecast event with its band", () => {
for (const e of events("MARGIN", [CYCLE], "DESCRIPTIVE")) {
expect(e.description).toBe("Most likely start Nov 17, ±2 days");
}
for (const e of events("EXACT")) expect(e.description).toBe("±2 days");
});
});
+1
View File
@@ -48,6 +48,7 @@ export const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "BAND" as const,
};
export const SUBJECT = {
@@ -54,6 +54,7 @@ const FEED_ROW = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET",
forecastStyle: "BAND",
};
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
+7 -1
View File
@@ -30,7 +30,11 @@ import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { icsContentHash, type IcsCalendar } from "../ics";
import { buildFeedCalendar, type FeedTitleMode } from "./events";
import {
buildFeedCalendar,
type FeedForecastStyle,
type FeedTitleMode,
} from "./events";
import { historyPeriods, historyWindowStart } from "./history";
/** The feed options the body depends on. */
@@ -40,6 +44,7 @@ export interface FeedOptions {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
/** The record the feed serves, as far as building it needs. */
@@ -147,6 +152,7 @@ export async function buildCycleFeed(
const calendar = buildFeedCalendar({
feedId: feed.id,
titleMode: feed.titleMode,
forecastStyle: feed.forecastStyle,
projected: projection.cycles,
history,
fertile,
+85 -19
View File
@@ -11,6 +11,13 @@
* DISCREET (the default) titles every event with a dot and names the calendar
* "HealthLog", so a glance at a shared screen or a lock-screen notification
* says nothing. DESCRIPTIVE names the period, in the record's own language.
*
* A forecast period is drawn in one of three styles. EXACT is the period as
* calculated: the most likely first day for the predicted number of bleeding
* days. MARGIN is the same event, plus the days the period may start earlier
* or run on later as their own "possible" events, so the calculated days stay
* readable as such. BAND is one event over every day the period may fall on,
* from the earliest likely start to the last bleeding day of the latest.
*/
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import type { ProjectedCycle } from "@/lib/cycle/projection";
@@ -21,9 +28,12 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
/** The discreet title: one dot, no word. */
export const DISCREET_SUMMARY = "●";
/** The discreet title of a MARGIN style's possible days: an open dot. */
export const DISCREET_MARGIN_SUMMARY = "○";
/** The discreet calendar name. */
export const DISCREET_CALENDAR_NAME = "HealthLog";
@@ -39,6 +49,7 @@ export interface HistoryPeriod {
export interface FeedEventInput {
feedId: string;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
projected: readonly ProjectedCycle[];
history: readonly HistoryPeriod[];
/** The next fertile window, already gated; null when not shown. */
@@ -63,26 +74,81 @@ function bandText(days: number, translator: ServerTranslator): string {
);
}
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
/** The forecast's description: the likely start and the band, or the band. */
function forecastDescription(p: ProjectedCycle, input: FeedEventInput): string {
const { translator } = input;
const band = bandText(dayDiff(p.bandEnd, p.start), translator);
return input.titleMode === "DESCRIPTIVE"
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band,
})
: band;
}
/**
* One forecast cycle's events. DTEND is exclusive throughout, so the period
* itself ends the day after its last bleeding day.
*/
function forecastEvents(p: ProjectedCycle, input: FeedEventInput): IcsEvent[] {
const { translator } = input;
const band = dayDiff(p.bandEnd, p.start);
const descriptive = input.titleMode === "DESCRIPTIVE";
return {
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start through
// the last bleeding day of the latest likely start. DTEND is exclusive.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY,
description: descriptive
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band: bandText(band, translator),
})
: bandText(band, translator),
};
const description = forecastDescription(p, input);
const periodSummary = descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY;
if (input.forecastStyle === "BAND") {
return [
{
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start
// through the last bleeding day of the latest likely start.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: periodSummary,
description,
},
];
}
const periodEnd = addDays(p.start, p.periodLength);
const events: IcsEvent[] = [
{
uid: eventUid(input.feedId, `pred:${p.k}`),
start: p.start,
endExclusive: periodEnd,
summary: periodSummary,
description,
},
];
if (input.forecastStyle === "EXACT") return events;
const marginSummary = descriptive
? translator.t("cycle.feed.ics.periodPossible")
: DISCREET_MARGIN_SUMMARY;
// The days before the likely start it may begin on, and the days after the
// likely period it may still run on. A side with no band has no event.
if (dayDiff(p.start, p.bandStart) > 0) {
events.push({
uid: eventUid(input.feedId, `pred:${p.k}:before`),
start: p.bandStart,
endExclusive: p.start,
summary: marginSummary,
description,
});
}
const bandEndExclusive = addDays(p.bandEnd, p.periodLength);
if (dayDiff(bandEndExclusive, periodEnd) > 0) {
events.push({
uid: eventUid(input.feedId, `pred:${p.k}:after`),
start: periodEnd,
endExclusive: bandEndExclusive,
summary: marginSummary,
description,
});
}
return events;
}
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
@@ -116,7 +182,7 @@ function fertileEvent(
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
const events: IcsEvent[] = [
...input.history.map((h) => historyEvent(h, input)),
...input.projected.map((p) => forecastEvent(p, input)),
...input.projected.flatMap((p) => forecastEvents(p, input)),
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
return {
+1
View File
@@ -116,6 +116,7 @@ const FEED_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
/**
+5 -1
View File
@@ -10,7 +10,7 @@ import type { ProjectionState } from "@/lib/cycle/projection";
import { buildCycleFeed, type FeedSubject } from "./build";
import { fetcherFamily, type FetcherFamily } from "./fetcher";
import type { FeedTitleMode } from "./events";
import type { FeedForecastStyle, FeedTitleMode } from "./events";
export const FEED_SUMMARY_SELECT = {
id: true,
@@ -23,6 +23,7 @@ export const FEED_SUMMARY_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
export interface FeedSummaryRow {
@@ -36,6 +37,7 @@ export interface FeedSummaryRow {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedSummary {
@@ -49,6 +51,7 @@ export interface CycleFeedSummary {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedStatus {
@@ -74,6 +77,7 @@ export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
historyCycles: row.historyCycles,
includeFertile: row.includeFertile,
titleMode: row.titleMode,
forecastStyle: row.forecastStyle,
};
}
+4 -4
View File
@@ -1406,7 +1406,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
const d = buildDailyDigest(
input({
...crowd,
preventiveDue: [{ label: "Skin check", staysDue: true }],
preventiveDue: [{ label: "Skin check", longCycle: true }],
}),
t,
);
@@ -1416,7 +1416,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
expect(d.worthALook[0].kind).toBe("dose_window");
});
it("does not pin a short-cycle reminder: it rolls on anyway", () => {
it("does not pin a short-cycle reminder: it comes round within days", () => {
const d = buildDailyDigest(
input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }),
t,
@@ -1436,7 +1436,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
const d = buildDailyDigest(
input({
...crowd,
preventiveDue: [{ label: "Skin check", staysDue: true }],
preventiveDue: [{ label: "Skin check", longCycle: true }],
upcomingVisits: [visit()],
}),
t,
@@ -1546,7 +1546,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
it("leaves the due check-ups item on the checkups list", () => {
const d = buildDailyDigest(
input({
preventiveDue: [{ label: "Skin check", staysDue: true }],
preventiveDue: [{ label: "Skin check", longCycle: true }],
upcomingVisits: [visit()],
}),
t,
+7 -7
View File
@@ -166,11 +166,11 @@ export interface DailyDigestPreventiveDue {
/** Display label, already resolved (a Coach cadence key is translated). */
label: string;
/**
* Whether this reminder stays due after it is sent (a cycle longer than a
* week, or none). Only such a reminder earns the check-up item a place
* under the rail cap; a short-cycle rhythm rolls on by itself.
* Whether this reminder is on a cycle longer than a week, or has none
* (`hasLongCycle`). Only such a reminder earns the check-up item a place
* under the rail cap; a short-cycle rhythm comes round again within days.
*/
staysDue?: boolean;
longCycle?: boolean;
}
/**
@@ -743,8 +743,8 @@ function buildUpcomingVisitItems(
/**
* The bounded rail: at most `max` items, in priority order, with the pinned
* items guaranteed a place. A pinned item is one a person can miss for good if
* the cap drops it today: a reminder that stays due and is due or overdue
* (its notification has already gone out, and it will not roll on by itself)
* the cap drops it today: a reminder on a long cycle that is due or overdue
* (its notification has already gone out, and the next one is a week away)
* and a visit booked for today (the day ends). The rest fill
* the remaining slots in order, and the result keeps the priority order, so an
* overdue dose still leads.
@@ -1207,7 +1207,7 @@ export function buildDailyDigest(
// `capWithPinned`); they still sit below a dose in the displayed order.
const pinned = new Set<PriorityItem>(
[
input.preventiveDue.some((due) => due.staysDue) ? preventive : null,
input.preventiveDue.some((due) => due.longCycle) ? preventive : null,
visits.today,
].filter((item): item is PriorityItem => item !== null),
);
+12 -12
View File
@@ -28,7 +28,7 @@ import {
import { userDayKey } from "@/lib/tz/format";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
import { hasLongCycle } from "@/lib/measurement-reminders/holds-open";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { isArrivalKind } from "@/lib/arrivals/types";
@@ -592,9 +592,9 @@ export async function loadDailyDigest(
//
// Read up to the end of the person's local day; the filter below keeps
// what is due now, plus what is due later today when it is a reminder
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
// reminder keeps its due date after it is sent, so the overdue ones
// stay here until they are satisfied, skipped or snoozed.
// on a long cycle (see `hasLongCycle`). A reminder that stays due after
// it is sent (`holdsOpenAfterReminder`) keeps its due date, so the
// overdue ones stay here until they are satisfied, skipped or snoozed.
where: {
userId: user.id,
enabled: true,
@@ -896,21 +896,21 @@ export async function loadDailyDigest(
// the checkups page and the dashboard card translate it, and so does this
// line. A free-text label (every other row) is the person's own words.
//
// A reminder that stays due after it is sent (a cycle longer than a week,
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
// whole due day and keeps its place under the cap. A short-cycle one (a
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
// time has come, is not pinned, and its next reading moves it on.
// A reminder on a cycle longer than a week (a fortnightly questionnaire, a
// yearly check-up) is on the rail for its whole due day and keeps its place
// under the cap. A short-cycle one (a daily weigh-in, a twice-daily course)
// is a rhythm: it shows only once its time has come and is not pinned; a
// missed rolling one then stays here, overdue, until its reading lands.
const preventiveDue: DailyDigestPreventiveDue[] = [];
for (const row of dueReminders) {
if (row.nextDueAt === null) continue;
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
const longCycle = hasLongCycle(row, user.timezone, row.nextDueAt);
if (!longCycle && row.nextDueAt.getTime() > now.getTime()) continue;
let label = row.label;
if (row.origin === "COACH") {
label = translateOrNull(t, row.label) ?? label;
}
preventiveDue.push({ label, staysDue });
preventiveDue.push({ label, longCycle });
}
// Group the two figures for the reader. `Intl.NumberFormat` is the only
+1
View File
@@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [
"scoreSleep",
"scoreStress",
"scoreStrain",
"bloodKetones", // #1367 — the chart on the ketones page.
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
+15
View File
@@ -29,6 +29,7 @@ import {
import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores";
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
import { readDayBands, shapeDayValues } from "@/lib/day/values";
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import {
resolveDayReadings,
type DayReadings,
@@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
]);
const presentTypes = [...readings.rowsByType.keys()];
// The index reveals the glucose it was built from, so it needs both
// reading types visible, not only the ketones the day holds.
const gkiReadable =
readings.rowsByType.has("BLOOD_KETONES") &&
typeVisible("BLOOD_KETONES") &&
typeVisible("BLOOD_GLUCOSE");
const glucoseKetoneIndex = gkiReadable
? await readGlucoseKetoneIndex({
userId: recordId,
from: dayStart,
to: dayEnd,
})
: [];
const [bands, notable] =
presentTypes.length === 0
? [new Map(), []]
@@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
counts: { values: values.length, entries: events.length },
running,
values,
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
events,
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
scores,
+24
View File
@@ -117,6 +117,24 @@ const dayValue = z
})
.meta({ id: "DayValue" });
/**
* #1367 — one glucose-ketone index of the day, derived from a ketone reading
* and the glucose reading paired with it; never stored.
*/
const dayGlucoseKetoneIndex = z
.object({
at: instant.describe("The ketone reading's instant."),
value: z.number().describe("The index, to one decimal."),
ketoneId: z.string().describe("The ketone reading's id."),
glucoseId: z.string().describe("The paired glucose reading's id."),
glucoseAt: instant.describe("The paired glucose reading's instant."),
ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."),
glucoseMmol: z
.number()
.describe("The paired glucose in mmol/L, one decimal."),
})
.meta({ id: "DayGlucoseKetoneIndex" });
const dayEvent = z
.object({
at: instant
@@ -232,6 +250,12 @@ export const dayResponseSchema = z
}),
running: z.array(dayRunningItem),
values: z.array(dayValue),
glucoseKetoneIndex: z
.array(dayGlucoseKetoneIndex)
.optional()
.describe(
"#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.",
),
events: z.array(dayEvent),
notable: z.array(dayNotable),
scores: z
@@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow {
}
/**
* Per-context glucose rows. Values are stored canonically in mg/dL and print
* in the owner's unit, in the precision their readings carry.
* The glucose group's own rows: one per meal context, stored canonically in
* mg/dL and printed in the owner's unit in the precision their readings
* carry, then the glucose-ketone index (#1367), a ratio without a unit.
*/
function glucoseContextTableRows(
context: DoctorReportPdfRenderContext,
@@ -209,6 +210,17 @@ function glucoseContextTableRows(
String(s.count),
]);
}
const gki = data.glucoseKetoneIndex;
if (gki) {
rows.push([
t("measurements.glucoseKetoneIndex"),
num(gki.latest, 1),
num(gki.avg, 1),
num(gki.min, 1),
num(gki.max, 1),
String(gki.count),
]);
}
return rows;
}
+7
View File
@@ -118,6 +118,13 @@ export interface DoctorReportData {
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
/**
* #1367 — the glucose-ketone index over the period (count, mean, range,
* latest), derived at read time from each ketone reading and the glucose
* reading paired with it. Null when there is none or when either reading
* type is withheld. Optional so older fixtures still typecheck.
*/
glucoseKetoneIndex?: DoctorReportStats | null;
bmi: number | null;
/** Keyed by medication id; see `DoctorReportCompliance`. */
compliance: Record<string, DoctorReportCompliance>;
+4 -3
View File
@@ -67,6 +67,7 @@ import {
import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { readReportGki } from "./glucose-ketone-index";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import { loadReportWorkouts } from "./workouts";
import { loadReportMedicationEffects } from "./medication-effects";
@@ -249,9 +250,8 @@ export async function collectDoctorReportData(
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
},
orderBy: { measuredAt: "asc" },
// Narrow select: the collector reads exactly these seven fields.
// The full-width read pulled every column — including the encrypted
// notes — across up to 730 days of rows.
// Narrow select: the full-width read pulled every column, encrypted
// notes included, across up to 730 days of rows.
select: {
type: true,
value: true,
@@ -739,6 +739,7 @@ export async function collectDoctorReportData(
glucoseStats,
glucoseRanges,
glucoseClinical,
glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end),
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
@@ -0,0 +1,45 @@
/**
* The glucose-ketone index in the doctor report (#1367): count, mean, range
* and latest over the report window, read by the one pairing rule
* (`read-glucose-ketone-index.ts`) so the report carries the numbers the
* ketones page shows.
*
* Both leaves must admit it. The index is glucose over ketones, so beside the
* ketone series it gives the glucose back exactly: a report that withholds
* glucose withholds the index too.
*/
import type { DoctorReportStats } from "@/lib/doctor-report-types";
import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index";
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import type { ReportGate } from "./selection-gate";
export async function readReportGki(
gate: ReportGate,
/** The report's own stats: no ketone row in the window, no read. */
stats: Readonly<Record<string, DoctorReportStats>>,
userId: string,
/** The report window; `end` is inclusive, as the measurement read has it. */
start: Date,
end: Date,
): Promise<DoctorReportStats | null> {
if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) {
return null;
}
if (!stats.BLOOD_KETONES?.count) return null;
const points = await readGlucoseKetoneIndex({
userId,
from: start,
to: new Date(end.getTime() + 1),
});
const summary = summariseGlucoseKetoneIndex(points);
if (!summary) return null;
return {
avg: summary.mean,
min: summary.min,
max: summary.max,
count: summary.count,
latest: summary.latest,
latestAt: points[points.length - 1]!.at,
};
}
@@ -193,6 +193,60 @@ 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('sends a "1 (calc)" unit to review like a plain "1"', async () => {
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "OBSERVATION",
label: "CHOL/HDLC RATIO",
value: 4,
unit: "1 (calc)",
confidence: 0.95,
sourceText: "CHOL/HDLC RATIO 4.0 (calc)",
},
],
}),
);
const result = await runInboundExtraction(oneRead(provider));
expect((result.facts[0].data as ObservationFactData).unit).toBe("1");
expect(result.facts[0].needsReview).toBe(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.
+22 -3
View File
@@ -58,6 +58,7 @@ import {
type OcrReadSurface,
} from "@/lib/documents/ocr-read";
import { isDimensionlessUnit } from "@/lib/labs/dimensionless";
import { withoutLabUnitAnnotation } from "@/lib/labs/unit-normalise";
import { annotate } from "@/lib/logging/context";
import {
INBOUND_CONFIDENCE_FLOOR,
@@ -168,7 +169,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
codeSystem: system === "LOINC" ? system : null,
value: hasNumeric ? raw.value : null,
valueText: hasNumeric ? null : raw.valueText,
unit: raw.unit,
unit: observationUnit(raw.unit),
referenceLow: raw.referenceLow,
referenceHigh: raw.referenceHigh,
referenceText: raw.referenceText,
@@ -189,6 +190,17 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
return data;
}
/**
* The printed unit without a lab's "(calc)" note, so the same marker read
* from two reports, one with the note and one without, lands in one unit.
* A unit that was only the note is no unit.
*/
function observationUnit(unit: string | null): string | null {
if (unit === null) return null;
const bare = withoutLabUnitAnnotation(unit);
return bare === "" ? null : bare;
}
/** A staged fact ready to persist (before it gets an id). */
export interface StagedFactInput {
factType: InboundFactType;
@@ -306,7 +318,7 @@ function factKey(raw: ExtractedFactRaw, readDate: string | null): string {
raw.label.trim().toLowerCase(),
raw.value ?? "",
(raw.valueText ?? "").trim().toLowerCase(),
(raw.unit ?? "").trim().toLowerCase(),
(observationUnit(raw.unit) ?? "").toLowerCase(),
(raw.referenceText ?? "").trim().toLowerCase(),
(raw.dose ?? "").trim().toLowerCase(),
normaliseDate(raw.effectiveDate) ?? readDate ?? "",
@@ -381,15 +393,22 @@ export async function runInboundExtraction(
// every fact from it is unanchored by construction.
const anchor = anchorSourceText(raw.sourceText, args.ocrText);
if (!anchor.anchored) unanchored += 1;
// The unit as it will be stored, with any "(calc)" note gone, so a
// "1 (calc)" is judged as the "1" it becomes.
const unit = observationUnit(raw.unit);
facts.push({
factType: raw.type,
confidence,
// 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(unit) ||
(raw.unit !== null && raw.unit.trim() !== "" && unit === null))),
data,
provenance: {
sourceText: anchor.sourceText,
+2
View File
@@ -325,6 +325,7 @@ export async function resolveClosableReminder(
lastSatisfiedAt: true,
lastSkippedAt: true,
createdAt: true,
endsOn: true,
},
});
if (!row || row.userId !== userId || row.deletedAt !== null) return undefined;
@@ -340,6 +341,7 @@ export async function resolveClosableReminder(
lastSatisfiedAt: row.lastSatisfiedAt,
lastSkippedAt: row.lastSkippedAt,
createdAt: row.createdAt,
endsOn: row.endsOn,
};
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more