mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge pull request #1365 from MBombeck/release/v1.47.2
release: v1.47.2
This commit is contained in:
182 files changed
+8355
-610
No files matched your search
+132
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -163,6 +163,7 @@ hand-authored.
|
||||
| `RESPIRATORY_RATE` | count/min |
|
||||
| `OXYGEN_SATURATION` | percent (0–100) |
|
||||
| `BLOOD_GLUCOSE` | mg/dL |
|
||||
| `BLOOD_KETONES` | mmol/L |
|
||||
| `BODY_FAT` | percent (0–100) |
|
||||
| `FAT_MASS` | kg |
|
||||
| `FAT_FREE_MASS` | kg |
|
||||
|
||||
@@ -32,16 +32,23 @@ managed on the web; the iOS app shows where to find it.
|
||||
## What the feed shows
|
||||
|
||||
- The next period and, once three cycles are logged, a few after it (three
|
||||
by default, up to six). Each event covers the whole window the period may
|
||||
fall in, and the window widens the further out it is. The list stops itself
|
||||
where a date would be more guess than estimate (beyond about ±10 days, or
|
||||
half a cycle).
|
||||
by default, up to six). The range a period may fall in widens the further
|
||||
out it is, and the list stops itself where a date would be more guess
|
||||
than estimate (beyond about ±10 days, or half a cycle). How that range is
|
||||
drawn is up to you, under "How forecasts look":
|
||||
- **Expected days with margin** (the default for a new subscription): the
|
||||
expected days as one event, and the days the period may start earlier
|
||||
or run on later as separate "Period possible" events.
|
||||
- **Expected days**: only the expected days, as one event.
|
||||
- **One range**: one event covering the whole range. A subscription
|
||||
turned on before v1.47.2 keeps this style until you change it, and
|
||||
renewing the link keeps whichever style it has.
|
||||
- Optionally, your past cycles (up to twelve).
|
||||
- Optionally, when your goal is trying to conceive, the estimated fertile
|
||||
window of the next cycle. It is an estimate, not a contraceptive method.
|
||||
|
||||
Titles are discreet by default: each event is a dot and the calendar is called
|
||||
"HealthLog". Descriptive titles name the period instead. Events never carry
|
||||
Titles are discreet by default: a period is a dot (●), a possible day an open
|
||||
dot (○), and the calendar is called "HealthLog". Descriptive titles name the period instead. Events never carry
|
||||
symptoms, notes or any other detail of your log.
|
||||
|
||||
When a period is later than its window, the feed shows no forecast until you
|
||||
|
||||
+60
-1
@@ -813,6 +813,10 @@
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeBloodKetones": "Blutketone",
|
||||
"glucoseKetoneIndex": "Glukose-Keton-Index",
|
||||
"pairedGlucoseLabel": "Blutzucker aus derselben Messung ({unit})",
|
||||
"pairedGlucoseHint": "Wird als eigener Blutzuckerwert mit derselben Uhrzeit gespeichert; aus dem Paar ergibt sich der Glukose-Keton-Index.",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
@@ -4002,6 +4006,23 @@
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glukose",
|
||||
"navBloodKetones": "Ketone",
|
||||
"bloodKetones": {
|
||||
"title": "Blutketone",
|
||||
"description": "Deine Beta-Hydroxybutyrat-Werte im Zeitverlauf, in {unit}.",
|
||||
"chartTitle": "Blutketone",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Ketonwerte",
|
||||
"description": "Ketonwerte erscheinen hier, sobald du einen erfasst, allein oder mit dem Blutzucker aus demselben Fingerstich.",
|
||||
"cta": "Ketone erfassen"
|
||||
},
|
||||
"gki": {
|
||||
"description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.",
|
||||
"emptyTitle": "Noch kein Index",
|
||||
"emptyDescription": "Er erscheint, sobald zu einem Ketonwert über null ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.",
|
||||
"latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}."
|
||||
}
|
||||
},
|
||||
"totalBodyWater": {
|
||||
"title": "Gesamtkörperwasser",
|
||||
"description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.",
|
||||
@@ -10056,6 +10077,17 @@
|
||||
"DISCREET": "Diskret (●)",
|
||||
"DESCRIPTIVE": "Beschreibend"
|
||||
},
|
||||
"forecasts": "Darstellung der Schätzung",
|
||||
"forecastStyle": {
|
||||
"EXACT": "Nur erwartete Tage",
|
||||
"MARGIN": "Erwartete Tage mit Spielraum",
|
||||
"BAND": "Ein Zeitraum"
|
||||
},
|
||||
"forecastStyleHint": {
|
||||
"EXACT": "Nur die erwarteten Tage.",
|
||||
"MARGIN": "Die erwarteten Tage, dazu als eigene Termine die Tage davor und danach, falls die Periode früher oder später beginnt.",
|
||||
"BAND": "Ein Termin über alle Tage, auf die die Periode fallen kann."
|
||||
},
|
||||
"fertile": "Fruchtbares Fenster",
|
||||
"fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.",
|
||||
"rotate": "Link erneuern",
|
||||
@@ -10075,6 +10107,7 @@
|
||||
"periodEstimate": "Periode (Schätzung)",
|
||||
"periodLogged": "Periode (erfasst)",
|
||||
"fertileEstimate": "Fruchtbares Fenster (Schätzung)",
|
||||
"periodPossible": "Periode möglich",
|
||||
"likelyStart": "Wahrscheinlicher Beginn {date}, {band}",
|
||||
"bandOne": "±{count} Tag",
|
||||
"bandFew": "±{count} Tage",
|
||||
@@ -10584,7 +10617,9 @@
|
||||
"openExpanded": "Alle",
|
||||
"openFlagged": "Nur außerhalb des Bereichs",
|
||||
"openCollapsed": "Keine",
|
||||
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie."
|
||||
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie.",
|
||||
"orderHeading": "Reihenfolge der Kategorien",
|
||||
"orderDefault": "Bis du eine verschiebst, stehen eingebaute Kategorien in fester Folge, deine eigenen alphabetisch und {other} zuletzt."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Befundbereich {range}",
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 523,
|
||||
"total": 5476,
|
||||
"total": 5475,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -440,7 +440,7 @@
|
||||
"lib/jobs/__tests__/insight-status-generate.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-reminder.test.ts": 33,
|
||||
"lib/jobs/__tests__/measurement-reminder.test.ts": 32,
|
||||
"lib/jobs/__tests__/medication-intake-import.test.ts": 1,
|
||||
"lib/jobs/__tests__/medication-reminder-check.test.ts": 27,
|
||||
"lib/jobs/__tests__/mood-reminder.test.ts": 15,
|
||||
|
||||
@@ -118,13 +118,20 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("the calendar feed body", () => {
|
||||
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
|
||||
"carries no day-log content in %s mode",
|
||||
async (titleMode) => {
|
||||
it.each(
|
||||
(["DISCREET", "DESCRIPTIVE"] as const).flatMap((titleMode) =>
|
||||
(["EXACT", "MARGIN", "BAND"] as const).map(
|
||||
(forecastStyle) => [titleMode, forecastStyle] as const,
|
||||
),
|
||||
),
|
||||
)(
|
||||
"carries no day-log content in %s mode, %s forecasts",
|
||||
async (titleMode, forecastStyle) => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode,
|
||||
forecastStyle,
|
||||
historyCycles: 12,
|
||||
horizonCycles: 6,
|
||||
includeFertile: true,
|
||||
@@ -146,6 +153,7 @@ describe("the calendar feed body", () => {
|
||||
{
|
||||
...FEED,
|
||||
titleMode: "DESCRIPTIVE",
|
||||
forecastStyle: "MARGIN",
|
||||
historyCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
|
||||
@@ -149,6 +149,8 @@ const ALLOWED_SQL: Record<string, string> = {
|
||||
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
|
||||
"lib/dashboard/summary-reads.ts::1":
|
||||
"Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.",
|
||||
"lib/measurements/read-glucose-ketone-index.ts::1":
|
||||
"Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.",
|
||||
"lib/dashboard/summary-reads.ts::2":
|
||||
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
|
||||
};
|
||||
|
||||
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(111);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
|
||||
expect(ALL_LEAF_IDS.length).toBe(112);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/labs/overview/route.ts": {
|
||||
domain: "labs",
|
||||
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
|
||||
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.",
|
||||
},
|
||||
"app/api/biomarkers/route.ts": {
|
||||
domain: "labs",
|
||||
|
||||
@@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
reason: "the onboarding unit picker: its options are the two units",
|
||||
},
|
||||
"components/measurements/measurement-form.tsx": {
|
||||
lines: 11,
|
||||
lines: 12,
|
||||
reason:
|
||||
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
|
||||
},
|
||||
@@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
reason: "the legacy export endpoint's canonical storage-unit contract",
|
||||
},
|
||||
"lib/measurements/series/kinds.ts": {
|
||||
lines: 4,
|
||||
lines: 5,
|
||||
reason:
|
||||
"canonical wire units; glucose is overridden to the reader's unit per request",
|
||||
"canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice",
|
||||
},
|
||||
"lib/doctor-report/stat-display.ts": {
|
||||
lines: 3,
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
*
|
||||
* One transaction: the live feed is revoked and its successor minted with the
|
||||
* same options, so there is never a moment with two live links or with none.
|
||||
* The forecast style is copied too: a feed minted before that option keeps
|
||||
* BAND rather than taking a new feed's default.
|
||||
* Calendars holding the old link stop updating on their next poll. The new
|
||||
* URL is returned exactly once. Two rotations racing both revoke the same
|
||||
* feed, and the second successor then meets the one-live-feed index: that
|
||||
* one gets the mint path's 409 `cycle.feed.exists`, and the winner's link
|
||||
* stands.
|
||||
* URL is returned exactly once. Of two rotations racing, the second finds
|
||||
* the feed already revoked (or its successor meets the one-live-feed index)
|
||||
* and gets the mint path's 409 `cycle.feed.exists`; the winner's link
|
||||
* stands. A subscription turned off during a rotation stays off, whichever
|
||||
* commits first, and the rotation then answers 404 `cycle.feed.none`; an
|
||||
* option changed during one lands on the link that stays live.
|
||||
*
|
||||
* Same gates as turning the subscription on: cookie session only, the owner or
|
||||
* the guardian of a managed profile (`requireGuardianAuth`), a fresh proof,
|
||||
@@ -33,7 +37,11 @@ import {
|
||||
feedLinks,
|
||||
noLiveFeed,
|
||||
} from "@/lib/cycle/feed/manage";
|
||||
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
|
||||
import {
|
||||
FEED_SUMMARY_SELECT,
|
||||
findLiveFeed,
|
||||
toFeedSummary,
|
||||
} from "@/lib/cycle/feed/status";
|
||||
import { mintFeedToken } from "@/lib/cycle/feed/token";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrigin } from "@/lib/http/origin";
|
||||
@@ -70,21 +78,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const minted = mintFeedToken();
|
||||
const rotated = await prisma
|
||||
.$transaction(async (tx) => {
|
||||
const live = await tx.cycleCalendarFeed.findFirst({
|
||||
const found = await tx.cycleCalendarFeed.findFirst({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) return null;
|
||||
// Only while it is still live: a subscription turned off after the
|
||||
// read above stays off instead of coming back under a new link.
|
||||
const revoked = await tx.cycleCalendarFeed.updateMany({
|
||||
where: { id: found.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (revoked.count === 0) return "raced" as const;
|
||||
// The options are read after the revoke holds the row, so a change
|
||||
// that committed in between is the one the new link carries, and one
|
||||
// that comes later waits and then finds the new link.
|
||||
const live = await tx.cycleCalendarFeed.findUniqueOrThrow({
|
||||
where: { id: found.id },
|
||||
select: {
|
||||
id: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
await tx.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const created = await tx.cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -95,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
forecastStyle: live.forecastStyle,
|
||||
},
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
@@ -113,7 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (isP2002(err)) return "raced" as const;
|
||||
throw err;
|
||||
});
|
||||
if (rotated === "raced") return feedExists();
|
||||
// Lost to another rotation, the link that won stands; lost to the
|
||||
// subscription being turned off, there is none.
|
||||
if (rotated === "raced") {
|
||||
return (await findLiveFeed(user.id)) ? feedExists() : noLiveFeed();
|
||||
}
|
||||
if (!rotated) return noLiveFeed();
|
||||
|
||||
return apiSuccess(
|
||||
|
||||
@@ -59,7 +59,10 @@ import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
|
||||
import {
|
||||
NEW_FEED_FORECAST_STYLE,
|
||||
cycleFeedOptionsPatchSchema,
|
||||
} from "@/lib/validations/cycle-feed";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireGuardianAuth();
|
||||
@@ -110,6 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
userId: user.id,
|
||||
createdById: actor.id,
|
||||
forecastStyle: NEW_FEED_FORECAST_STYLE,
|
||||
tokenHash: minted.hash,
|
||||
tokenPrefix: minted.prefix,
|
||||
},
|
||||
@@ -166,7 +170,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const live = await findLiveFeed(user.id);
|
||||
let live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
|
||||
const data = {
|
||||
@@ -180,10 +184,29 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
? { includeFertile: input.includeFertile }
|
||||
: {}),
|
||||
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
|
||||
...(input.forecastStyle !== undefined
|
||||
? { forecastStyle: input.forecastStyle }
|
||||
: {}),
|
||||
};
|
||||
const updated = await prisma.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
// Only while the feed is still the live one. A rotation that committed
|
||||
// after the read above revoked it and copied its old options onto the new
|
||||
// link; the change then goes to that link, once. Updating the revoked row
|
||||
// would answer 200 with the old options left in force.
|
||||
let written = await prisma.cycleCalendarFeed.updateMany({
|
||||
where: { id: live.id, revokedAt: null },
|
||||
data,
|
||||
});
|
||||
if (written.count === 0) {
|
||||
live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
written = await prisma.cycleCalendarFeed.updateMany({
|
||||
where: { id: live.id, revokedAt: null },
|
||||
data,
|
||||
});
|
||||
if (written.count === 0) return feedExists();
|
||||
}
|
||||
const updated = await prisma.cycleCalendarFeed.findUniqueOrThrow({
|
||||
where: { id: live.id },
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
|
||||
@@ -197,12 +220,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
forecastStyle: live.forecastStyle,
|
||||
},
|
||||
after: {
|
||||
horizonCycles: updated.horizonCycles,
|
||||
historyCycles: updated.historyCycles,
|
||||
includeFertile: updated.includeFertile,
|
||||
titleMode: updated.titleMode,
|
||||
forecastStyle: updated.forecastStyle,
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -220,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 });
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { categoriseOverview } from "@/lib/labs/marker-category";
|
||||
import { loadLabOverview } from "@/lib/labs/overview";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveModuleListLayout } from "@/lib/settings/module-list-layout";
|
||||
import { labOverviewQuerySchema } from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
|
||||
* `categories` and `grouped` are the list's categories, resolved here once
|
||||
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
|
||||
* combined status. Labels follow the viewer's locale, so on a managed record
|
||||
* the caller's own language wins, not the record owner's.
|
||||
* the caller's own language wins, not the record owner's. The order is the
|
||||
* default one with the caller's `categoryOrder` (the labs list layout,
|
||||
* `PUT /api/settings/module-list-layout`) applied, for the same reason: it is
|
||||
* the viewer's presentation, so a person acting for another record sees the
|
||||
* categories in their own order. A client renders the order as given.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("read", "labs");
|
||||
@@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const markers = await loadLabOverview(user.id);
|
||||
const [markers, layoutRow] = await Promise.all([
|
||||
loadLabOverview(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: actor.id },
|
||||
select: { moduleListLayoutJson: true },
|
||||
}),
|
||||
]);
|
||||
const { categoryOrder } = resolveModuleListLayout(
|
||||
layoutRow?.moduleListLayoutJson,
|
||||
).lists.labs;
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: actor.locale ?? null,
|
||||
});
|
||||
const { categories, grouped } = categoriseOverview(markers, locale);
|
||||
const { categories, grouped } = categoriseOverview(
|
||||
markers,
|
||||
locale,
|
||||
categoryOrder,
|
||||
);
|
||||
annotate({
|
||||
action: { name: "labs.overview" },
|
||||
meta: { markers: markers.length, categories: categories.length, grouped },
|
||||
meta: {
|
||||
markers: markers.length,
|
||||
categories: categories.length,
|
||||
grouped,
|
||||
categoryOrderLength: categoryOrder.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ markers, categories, grouped });
|
||||
});
|
||||
@@ -272,8 +272,13 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
|
||||
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
|
||||
});
|
||||
|
||||
it("re-enabling a disabled short-cycle reminder schedules it from now", async () => {
|
||||
const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false };
|
||||
it("re-enabling a disabled weekly calendar rule schedules it from now", async () => {
|
||||
const weekly = {
|
||||
...BASE_ROW,
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=WEEKLY",
|
||||
enabled: false,
|
||||
};
|
||||
findFirstMock.mockResolvedValue(weekly);
|
||||
|
||||
const res = await PATCH(makeRequest({ enabled: true }), params);
|
||||
@@ -303,6 +308,26 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
|
||||
expect("nextDueAt" in persisted).toBe(false);
|
||||
});
|
||||
|
||||
it("re-enabling a missed short rolling reminder keeps its open slot", async () => {
|
||||
// Every two days, never measured: switching it off and on again is not
|
||||
// measuring it either.
|
||||
findFirstMock.mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
intervalDays: 2,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const res = await PATCH(makeRequest({ enabled: true }), params);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(persisted.enabled).toBe(true);
|
||||
expect("nextDueAt" in persisted).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a resent first due date on the same calendar day as unchanged", async () => {
|
||||
// A booster row stores the dose instant plus N days; the web form sends
|
||||
// the same day back as local midnight. Same day in the profile zone, so
|
||||
|
||||
@@ -219,7 +219,7 @@ export const PATCH = apiHandler(
|
||||
const reEnabled = updateData.enabled === true && !existing.enabled;
|
||||
// Re-enabling a reminder that stays due after it is sent keeps the slot it
|
||||
// was on, overdue or not: switching it off and on again is not doing it.
|
||||
// A short-cycle reminder is scheduled from now, as before.
|
||||
// A calendar rule that recurs within the week is scheduled from now.
|
||||
const keepsOpenSlot =
|
||||
reEnabled &&
|
||||
!cadenceChanged &&
|
||||
|
||||
@@ -58,14 +58,15 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
// 500 ids of up to 64 characters plus the envelope.
|
||||
// 500 ids plus 200 category keys of up to 64 characters, plus the envelope.
|
||||
const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, {
|
||||
maxBytes: 48 * 1024,
|
||||
maxBytes: 64 * 1024,
|
||||
action: "settings.module-list-layout.validation-failed",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } =
|
||||
parsed.data;
|
||||
// Preserve-when-absent: one stored read fills whichever field the body
|
||||
// left out, and every other list stays as stored. Only fields a person has
|
||||
// chosen are written, so a field never picked keeps following the default.
|
||||
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
...(sortDir ? { sortDir } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(groupsOpen ? { groupsOpen } : {}),
|
||||
...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}),
|
||||
};
|
||||
const next = { ...stored, [list]: nextChoices };
|
||||
await tx.user.update({
|
||||
@@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
groupBy: layout.lists[list].groupBy,
|
||||
groupsOpen: layout.lists[list].groupsOpen,
|
||||
orderLength: layout.lists[list].order.length,
|
||||
categoryOrderLength: layout.lists[list].categoryOrder.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(layout);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
|
||||
*
|
||||
* The answer is an empty page whose cursor sits a minute before `serverNow`
|
||||
* The answer is an empty page whose cursor sits two minutes before `serverNow`
|
||||
* in every domain (a write stamped before it and committed after it is not
|
||||
* skipped), in the feed's own opaque format, and nothing is read: the point is
|
||||
* to skip the history walk, so a read here would be the bug. The database is
|
||||
@@ -55,7 +55,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes?start=now", () => {
|
||||
it("answers an empty page with a cursor a minute before serverNow in every domain", async () => {
|
||||
it("answers an empty page with a cursor two minutes before serverNow in every domain", async () => {
|
||||
const before = Date.now();
|
||||
const res = await GET(req("?start=now"));
|
||||
const after = Date.now();
|
||||
@@ -82,7 +82,7 @@ describe("GET /api/sync/changes?start=now", () => {
|
||||
expect(cursor).not.toBeNull();
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
expect(cursor![domain], domain).toEqual({
|
||||
updatedAtMs: nowMs - 60_000,
|
||||
updatedAtMs: nowMs - 120_000,
|
||||
id: "0",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
* - `hasMore` is true when ANY served domain still has rows past its
|
||||
* page; the next `cursor` advances every domain that returned rows.
|
||||
* When `hasMore` is false the client is caught up as of `serverNow`.
|
||||
* A row domain that ran dry takes a watermark two minutes before
|
||||
* `serverNow` (`drainedWatermark`; on the last page every one does):
|
||||
* a row stamped in that stretch whose write commits after the pull
|
||||
* still arrives on the next one, at the cost of reading the stretch
|
||||
* twice, and a domain that stays quiet keeps a fresh watermark instead
|
||||
* of ageing into `cursorExpired`.
|
||||
* - `cursorExpired: true` when ANY domain watermark predates the
|
||||
* tombstone-retention horizon — the client drops its cursor and does
|
||||
* a clean initial sync (a deletion older than retention may have
|
||||
@@ -43,7 +49,7 @@
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits a minute
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits two minutes
|
||||
* before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same
|
||||
* opaque format, so a client that
|
||||
* only wants what changes from now on (the Apple Health write-back) pages
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Droplet } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { GkiCard } from "@/components/insights/ketones/gki-card";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
/** mmol/L, from the one canonical unit table; ketones have no unit choice. */
|
||||
const KETONE_UNIT = getUnitForType("BLOOD_KETONES");
|
||||
|
||||
/**
|
||||
* `/insights/ketones` (#1367).
|
||||
*
|
||||
* Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric
|
||||
* scaffold, with the glucose-ketone index beneath the chart. Readings are
|
||||
* typed off a meter, alone or together with the glucose from the same
|
||||
* fingerstick, so the empty state offers manual entry. No band and no
|
||||
* assessment card: what a ketone reading should be follows the plan a
|
||||
* clinician sets, and the page shows the readings as recorded.
|
||||
*/
|
||||
export default function InsightsKetonesPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_KETONES"
|
||||
insightMetric="BLOOD_KETONES"
|
||||
chartKey="bloodKetones"
|
||||
i18nPrefix="insights.bloodKetones"
|
||||
color="var(--chart-3)"
|
||||
unit={KETONE_UNIT}
|
||||
yAxisUnit={KETONE_UNIT}
|
||||
statFractionDigits={1}
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType="BLOOD_KETONES"
|
||||
captureType="BLOOD_KETONES"
|
||||
afterChart={<GkiCard />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -261,6 +261,21 @@ export function MeasurementGroups({
|
||||
max: show(type, stat!.max, digits),
|
||||
})}
|
||||
/>
|
||||
{type === "BLOOD_KETONES" &&
|
||||
report.glucoseKetoneIndex &&
|
||||
scope.admits("BLOOD_GLUCOSE") ? (
|
||||
// #1367 — the index the server derived from the ketone
|
||||
// and glucose readings, beside the ketones; no unit.
|
||||
<StatRow
|
||||
label={t("measurements.glucoseKetoneIndex")}
|
||||
value={statSummary(t, "", {
|
||||
latest: fmtDigits(report.glucoseKetoneIndex.latest, 1),
|
||||
avg: fmtDigits(report.glucoseKetoneIndex.avg, 1),
|
||||
min: fmtDigits(report.glucoseKetoneIndex.min, 1),
|
||||
max: fmtDigits(report.glucoseKetoneIndex.max, 1),
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{unvalidated > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,9 +29,9 @@ import {
|
||||
type DayEvent,
|
||||
type DayEventKind,
|
||||
type DayNotable,
|
||||
type DayResponse,
|
||||
type DayRunningItem,
|
||||
type DayRunningKind,
|
||||
type DayValue,
|
||||
} from "@/lib/day/contract";
|
||||
import { visitKindWords } from "@/components/timeline/item-words";
|
||||
import {
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
WORKOUT_SPORT_KEY,
|
||||
} from "./label-keys";
|
||||
import { cycleRunningLine } from "./day-cycle-line";
|
||||
import { DayGkiTile } from "./day-gki-tile";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
|
||||
/**
|
||||
@@ -508,12 +509,14 @@ function ValueTile({
|
||||
}
|
||||
|
||||
export function DayValues({
|
||||
values,
|
||||
day,
|
||||
focusTypes,
|
||||
}: {
|
||||
values: readonly DayValue[];
|
||||
/** The day's values and its server-derived glucose-ketone index (#1367). */
|
||||
day: Pick<DayResponse, "values" | "glucoseKetoneIndex">;
|
||||
focusTypes: readonly string[];
|
||||
}) {
|
||||
const { values } = day;
|
||||
const { t, tCount } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (values.length === 0) return null;
|
||||
@@ -537,6 +540,7 @@ export function DayValues({
|
||||
selected={focusKeys.has(tile.key)}
|
||||
/>
|
||||
))}
|
||||
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
|
||||
</ul>
|
||||
{rest.length > 0 ? (
|
||||
<button
|
||||
|
||||
@@ -296,7 +296,7 @@ export function DayView({
|
||||
<DayRunning items={data.running} date={date} />
|
||||
{data.values.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayValues values={data.values} focusTypes={focusTypes} />
|
||||
<DayValues day={data} focusTypes={focusTypes} />
|
||||
<DayNotableLines
|
||||
notables={otherNotables}
|
||||
labelFor={labelFor}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* #1350 — how a forecast period is drawn in each style. Dates only: the
|
||||
* projection is handed in, so every expected day below is the arithmetic of
|
||||
* one fixed cycle and nothing else.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectedCycle } from "@/lib/cycle/projection";
|
||||
import { eventUid } from "@/lib/cycle/ics";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
import {
|
||||
DISCREET_MARGIN_SUMMARY,
|
||||
DISCREET_SUMMARY,
|
||||
buildFeedCalendar,
|
||||
type FeedForecastStyle,
|
||||
type FeedTitleMode,
|
||||
} from "../events";
|
||||
|
||||
/** Most likely start 17 Nov, five bleeding days, ±2 days. */
|
||||
const CYCLE: ProjectedCycle = {
|
||||
k: 1,
|
||||
start: "2026-11-17",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-11-15",
|
||||
bandEnd: "2026-11-19",
|
||||
};
|
||||
|
||||
function events(
|
||||
forecastStyle: FeedForecastStyle,
|
||||
projected: ProjectedCycle[] = [CYCLE],
|
||||
titleMode: FeedTitleMode = "DISCREET",
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return buildFeedCalendar({
|
||||
feedId: "feed-1",
|
||||
titleMode,
|
||||
forecastStyle,
|
||||
projected,
|
||||
history: [],
|
||||
fertile: null,
|
||||
translator: getServerTranslator(locale),
|
||||
}).events;
|
||||
}
|
||||
|
||||
/** start .. endExclusive, the pair DTSTART / DTEND carry. */
|
||||
const span = (e: { start: string; endExclusive: string }) => [
|
||||
e.start,
|
||||
e.endExclusive,
|
||||
];
|
||||
|
||||
describe("forecast styles", () => {
|
||||
it("BAND draws one event from the earliest start to the latest period's end", () => {
|
||||
const out = events("BAND");
|
||||
expect(out.map(span)).toEqual([["2026-11-15", "2026-11-24"]]);
|
||||
expect(out[0].summary).toBe(DISCREET_SUMMARY);
|
||||
});
|
||||
|
||||
it("EXACT draws only the calculated period", () => {
|
||||
const out = events("EXACT");
|
||||
expect(out.map(span)).toEqual([["2026-11-17", "2026-11-22"]]);
|
||||
expect(out[0].summary).toBe(DISCREET_SUMMARY);
|
||||
});
|
||||
|
||||
it("MARGIN draws the calculated period with the possible days on each side", () => {
|
||||
const out = events("MARGIN");
|
||||
expect(out.map(span)).toEqual([
|
||||
["2026-11-15", "2026-11-17"],
|
||||
["2026-11-17", "2026-11-22"],
|
||||
["2026-11-22", "2026-11-24"],
|
||||
]);
|
||||
expect(out.map((e) => e.summary)).toEqual([
|
||||
DISCREET_MARGIN_SUMMARY,
|
||||
DISCREET_SUMMARY,
|
||||
DISCREET_MARGIN_SUMMARY,
|
||||
]);
|
||||
});
|
||||
|
||||
it("MARGIN covers exactly the days BAND covers, without overlap", () => {
|
||||
const [band] = events("BAND");
|
||||
const margin = events("MARGIN");
|
||||
expect(margin[0].start).toBe(band.start);
|
||||
expect(margin.at(-1)!.endExclusive).toBe(band.endExclusive);
|
||||
for (let i = 1; i < margin.length; i++) {
|
||||
expect(margin[i].start).toBe(margin[i - 1].endExclusive);
|
||||
}
|
||||
});
|
||||
|
||||
it("MARGIN draws no side event where the band is empty", () => {
|
||||
const exact = { ...CYCLE, bandStart: CYCLE.start, bandEnd: CYCLE.start };
|
||||
expect(events("MARGIN", [exact]).map(span)).toEqual([
|
||||
["2026-11-17", "2026-11-22"],
|
||||
]);
|
||||
const lateOnly = { ...CYCLE, bandStart: CYCLE.start };
|
||||
expect(events("MARGIN", [lateOnly]).map(span)).toEqual([
|
||||
["2026-11-17", "2026-11-22"],
|
||||
["2026-11-22", "2026-11-24"],
|
||||
]);
|
||||
const earlyOnly = { ...CYCLE, bandEnd: CYCLE.start };
|
||||
expect(events("MARGIN", [earlyOnly]).map(span)).toEqual([
|
||||
["2026-11-15", "2026-11-17"],
|
||||
["2026-11-17", "2026-11-22"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the period's UID across styles and gives each margin its own", () => {
|
||||
const main = eventUid("feed-1", "pred:1");
|
||||
expect(events("BAND")[0].uid).toBe(main);
|
||||
expect(events("EXACT")[0].uid).toBe(main);
|
||||
expect(events("MARGIN").map((e) => e.uid)).toEqual([
|
||||
eventUid("feed-1", "pred:1:before"),
|
||||
main,
|
||||
eventUid("feed-1", "pred:1:after"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("draws every forecast cycle it is given", () => {
|
||||
const next = {
|
||||
k: 2,
|
||||
start: "2026-12-15",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-12-12",
|
||||
bandEnd: "2026-12-18",
|
||||
};
|
||||
expect(events("EXACT", [CYCLE, next])).toHaveLength(2);
|
||||
expect(events("MARGIN", [CYCLE, next])).toHaveLength(6);
|
||||
expect(events("BAND", [CYCLE, next])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("names the possible days in the record's language when descriptive", () => {
|
||||
expect(
|
||||
events("MARGIN", [CYCLE], "DESCRIPTIVE").map((e) => e.summary),
|
||||
).toEqual(["Period possible", "Period (estimate)", "Period possible"]);
|
||||
expect(
|
||||
events("MARGIN", [CYCLE], "DESCRIPTIVE", "de").map((e) => e.summary),
|
||||
).toEqual(["Periode möglich", "Periode (Schätzung)", "Periode möglich"]);
|
||||
});
|
||||
|
||||
it("describes every forecast event with its band", () => {
|
||||
for (const e of events("MARGIN", [CYCLE], "DESCRIPTIVE")) {
|
||||
expect(e.description).toBe("Most likely start Nov 17, ±2 days");
|
||||
}
|
||||
for (const e of events("EXACT")) expect(e.description).toBe("±2 days");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export const FEED = {
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
forecastStyle: "BAND" as const,
|
||||
};
|
||||
|
||||
export const SUBJECT = {
|
||||
|
||||
@@ -54,6 +54,7 @@ const FEED_ROW = {
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET",
|
||||
forecastStyle: "BAND",
|
||||
};
|
||||
|
||||
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
|
||||
|
||||
@@ -30,7 +30,11 @@ import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { icsContentHash, type IcsCalendar } from "../ics";
|
||||
import { buildFeedCalendar, type FeedTitleMode } from "./events";
|
||||
import {
|
||||
buildFeedCalendar,
|
||||
type FeedForecastStyle,
|
||||
type FeedTitleMode,
|
||||
} from "./events";
|
||||
import { historyPeriods, historyWindowStart } from "./history";
|
||||
|
||||
/** The feed options the body depends on. */
|
||||
@@ -40,6 +44,7 @@ export interface FeedOptions {
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
}
|
||||
|
||||
/** The record the feed serves, as far as building it needs. */
|
||||
@@ -147,6 +152,7 @@ export async function buildCycleFeed(
|
||||
const calendar = buildFeedCalendar({
|
||||
feedId: feed.id,
|
||||
titleMode: feed.titleMode,
|
||||
forecastStyle: feed.forecastStyle,
|
||||
projected: projection.cycles,
|
||||
history,
|
||||
fertile,
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
* DISCREET (the default) titles every event with a dot and names the calendar
|
||||
* "HealthLog", so a glance at a shared screen or a lock-screen notification
|
||||
* says nothing. DESCRIPTIVE names the period, in the record's own language.
|
||||
*
|
||||
* A forecast period is drawn in one of three styles. EXACT is the period as
|
||||
* calculated: the most likely first day for the predicted number of bleeding
|
||||
* days. MARGIN is the same event, plus the days the period may start earlier
|
||||
* or run on later as their own "possible" events, so the calculated days stay
|
||||
* readable as such. BAND is one event over every day the period may fall on,
|
||||
* from the earliest likely start to the last bleeding day of the latest.
|
||||
*/
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import type { ProjectedCycle } from "@/lib/cycle/projection";
|
||||
@@ -21,9 +28,12 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
|
||||
|
||||
/** The discreet title: one dot, no word. */
|
||||
export const DISCREET_SUMMARY = "●";
|
||||
/** The discreet title of a MARGIN style's possible days: an open dot. */
|
||||
export const DISCREET_MARGIN_SUMMARY = "○";
|
||||
/** The discreet calendar name. */
|
||||
export const DISCREET_CALENDAR_NAME = "HealthLog";
|
||||
|
||||
@@ -39,6 +49,7 @@ export interface HistoryPeriod {
|
||||
export interface FeedEventInput {
|
||||
feedId: string;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
projected: readonly ProjectedCycle[];
|
||||
history: readonly HistoryPeriod[];
|
||||
/** The next fertile window, already gated; null when not shown. */
|
||||
@@ -63,26 +74,81 @@ function bandText(days: number, translator: ServerTranslator): string {
|
||||
);
|
||||
}
|
||||
|
||||
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
|
||||
/** The forecast's description: the likely start and the band, or the band. */
|
||||
function forecastDescription(p: ProjectedCycle, input: FeedEventInput): string {
|
||||
const { translator } = input;
|
||||
const band = bandText(dayDiff(p.bandEnd, p.start), translator);
|
||||
return input.titleMode === "DESCRIPTIVE"
|
||||
? translator.t("cycle.feed.ics.likelyStart", {
|
||||
date: shortDate(p.start, translator),
|
||||
band,
|
||||
})
|
||||
: band;
|
||||
}
|
||||
|
||||
/**
|
||||
* One forecast cycle's events. DTEND is exclusive throughout, so the period
|
||||
* itself ends the day after its last bleeding day.
|
||||
*/
|
||||
function forecastEvents(p: ProjectedCycle, input: FeedEventInput): IcsEvent[] {
|
||||
const { translator } = input;
|
||||
const band = dayDiff(p.bandEnd, p.start);
|
||||
const descriptive = input.titleMode === "DESCRIPTIVE";
|
||||
return {
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
// The whole window the period may fall in: earliest likely start through
|
||||
// the last bleeding day of the latest likely start. DTEND is exclusive.
|
||||
start: p.bandStart,
|
||||
endExclusive: addDays(p.bandEnd, p.periodLength),
|
||||
summary: descriptive
|
||||
? translator.t("cycle.feed.ics.periodEstimate")
|
||||
: DISCREET_SUMMARY,
|
||||
description: descriptive
|
||||
? translator.t("cycle.feed.ics.likelyStart", {
|
||||
date: shortDate(p.start, translator),
|
||||
band: bandText(band, translator),
|
||||
})
|
||||
: bandText(band, translator),
|
||||
};
|
||||
const description = forecastDescription(p, input);
|
||||
const periodSummary = descriptive
|
||||
? translator.t("cycle.feed.ics.periodEstimate")
|
||||
: DISCREET_SUMMARY;
|
||||
|
||||
if (input.forecastStyle === "BAND") {
|
||||
return [
|
||||
{
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
// The whole window the period may fall in: earliest likely start
|
||||
// through the last bleeding day of the latest likely start.
|
||||
start: p.bandStart,
|
||||
endExclusive: addDays(p.bandEnd, p.periodLength),
|
||||
summary: periodSummary,
|
||||
description,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const periodEnd = addDays(p.start, p.periodLength);
|
||||
const events: IcsEvent[] = [
|
||||
{
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
start: p.start,
|
||||
endExclusive: periodEnd,
|
||||
summary: periodSummary,
|
||||
description,
|
||||
},
|
||||
];
|
||||
if (input.forecastStyle === "EXACT") return events;
|
||||
|
||||
const marginSummary = descriptive
|
||||
? translator.t("cycle.feed.ics.periodPossible")
|
||||
: DISCREET_MARGIN_SUMMARY;
|
||||
// The days before the likely start it may begin on, and the days after the
|
||||
// likely period it may still run on. A side with no band has no event.
|
||||
if (dayDiff(p.start, p.bandStart) > 0) {
|
||||
events.push({
|
||||
uid: eventUid(input.feedId, `pred:${p.k}:before`),
|
||||
start: p.bandStart,
|
||||
endExclusive: p.start,
|
||||
summary: marginSummary,
|
||||
description,
|
||||
});
|
||||
}
|
||||
const bandEndExclusive = addDays(p.bandEnd, p.periodLength);
|
||||
if (dayDiff(bandEndExclusive, periodEnd) > 0) {
|
||||
events.push({
|
||||
uid: eventUid(input.feedId, `pred:${p.k}:after`),
|
||||
start: periodEnd,
|
||||
endExclusive: bandEndExclusive,
|
||||
summary: marginSummary,
|
||||
description,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
|
||||
@@ -116,7 +182,7 @@ function fertileEvent(
|
||||
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
|
||||
const events: IcsEvent[] = [
|
||||
...input.history.map((h) => historyEvent(h, input)),
|
||||
...input.projected.map((p) => forecastEvent(p, input)),
|
||||
...input.projected.flatMap((p) => forecastEvents(p, input)),
|
||||
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
|
||||
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
|
||||
return {
|
||||
|
||||
@@ -116,6 +116,7 @@ const FEED_SELECT = {
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ProjectionState } from "@/lib/cycle/projection";
|
||||
|
||||
import { buildCycleFeed, type FeedSubject } from "./build";
|
||||
import { fetcherFamily, type FetcherFamily } from "./fetcher";
|
||||
import type { FeedTitleMode } from "./events";
|
||||
import type { FeedForecastStyle, FeedTitleMode } from "./events";
|
||||
|
||||
export const FEED_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
@@ -23,6 +23,7 @@ export const FEED_SUMMARY_SELECT = {
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
} as const;
|
||||
|
||||
export interface FeedSummaryRow {
|
||||
@@ -36,6 +37,7 @@ export interface FeedSummaryRow {
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
}
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
@@ -49,6 +51,7 @@ export interface CycleFeedSummary {
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
@@ -74,6 +77,7 @@ export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
|
||||
historyCycles: row.historyCycles,
|
||||
includeFertile: row.includeFertile,
|
||||
titleMode: row.titleMode,
|
||||
forecastStyle: row.forecastStyle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1406,7 +1406,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
preventiveDue: [{ label: "Skin check", longCycle: true }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
@@ -1416,7 +1416,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
|
||||
it("does not pin a short-cycle reminder: it rolls on anyway", () => {
|
||||
it("does not pin a short-cycle reminder: it comes round within days", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }),
|
||||
t,
|
||||
@@ -1436,7 +1436,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
preventiveDue: [{ label: "Skin check", longCycle: true }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
@@ -1546,7 +1546,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
it("leaves the due check-ups item on the checkups list", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
preventiveDue: [{ label: "Skin check", longCycle: true }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
|
||||
@@ -166,11 +166,11 @@ export interface DailyDigestPreventiveDue {
|
||||
/** Display label, already resolved (a Coach cadence key is translated). */
|
||||
label: string;
|
||||
/**
|
||||
* Whether this reminder stays due after it is sent (a cycle longer than a
|
||||
* week, or none). Only such a reminder earns the check-up item a place
|
||||
* under the rail cap; a short-cycle rhythm rolls on by itself.
|
||||
* Whether this reminder is on a cycle longer than a week, or has none
|
||||
* (`hasLongCycle`). Only such a reminder earns the check-up item a place
|
||||
* under the rail cap; a short-cycle rhythm comes round again within days.
|
||||
*/
|
||||
staysDue?: boolean;
|
||||
longCycle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,8 +743,8 @@ function buildUpcomingVisitItems(
|
||||
/**
|
||||
* The bounded rail: at most `max` items, in priority order, with the pinned
|
||||
* items guaranteed a place. A pinned item is one a person can miss for good if
|
||||
* the cap drops it today: a reminder that stays due and is due or overdue
|
||||
* (its notification has already gone out, and it will not roll on by itself)
|
||||
* the cap drops it today: a reminder on a long cycle that is due or overdue
|
||||
* (its notification has already gone out, and the next one is a week away)
|
||||
* and a visit booked for today (the day ends). The rest fill
|
||||
* the remaining slots in order, and the result keeps the priority order, so an
|
||||
* overdue dose still leads.
|
||||
@@ -1207,7 +1207,7 @@ export function buildDailyDigest(
|
||||
// `capWithPinned`); they still sit below a dose in the displayed order.
|
||||
const pinned = new Set<PriorityItem>(
|
||||
[
|
||||
input.preventiveDue.some((due) => due.staysDue) ? preventive : null,
|
||||
input.preventiveDue.some((due) => due.longCycle) ? preventive : null,
|
||||
visits.today,
|
||||
].filter((item): item is PriorityItem => item !== null),
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
|
||||
import { hasLongCycle } from "@/lib/measurement-reminders/holds-open";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import { isArrivalKind } from "@/lib/arrivals/types";
|
||||
@@ -592,9 +592,9 @@ export async function loadDailyDigest(
|
||||
//
|
||||
// Read up to the end of the person's local day; the filter below keeps
|
||||
// what is due now, plus what is due later today when it is a reminder
|
||||
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
|
||||
// reminder keeps its due date after it is sent, so the overdue ones
|
||||
// stay here until they are satisfied, skipped or snoozed.
|
||||
// on a long cycle (see `hasLongCycle`). A reminder that stays due after
|
||||
// it is sent (`holdsOpenAfterReminder`) keeps its due date, so the
|
||||
// overdue ones stay here until they are satisfied, skipped or snoozed.
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
@@ -896,21 +896,21 @@ export async function loadDailyDigest(
|
||||
// the checkups page and the dashboard card translate it, and so does this
|
||||
// line. A free-text label (every other row) is the person's own words.
|
||||
//
|
||||
// A reminder that stays due after it is sent (a cycle longer than a week,
|
||||
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
|
||||
// whole due day and keeps its place under the cap. A short-cycle one (a
|
||||
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
|
||||
// time has come, is not pinned, and its next reading moves it on.
|
||||
// A reminder on a cycle longer than a week (a fortnightly questionnaire, a
|
||||
// yearly check-up) is on the rail for its whole due day and keeps its place
|
||||
// under the cap. A short-cycle one (a daily weigh-in, a twice-daily course)
|
||||
// is a rhythm: it shows only once its time has come and is not pinned; a
|
||||
// missed rolling one then stays here, overdue, until its reading lands.
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = [];
|
||||
for (const row of dueReminders) {
|
||||
if (row.nextDueAt === null) continue;
|
||||
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
|
||||
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
|
||||
const longCycle = hasLongCycle(row, user.timezone, row.nextDueAt);
|
||||
if (!longCycle && row.nextDueAt.getTime() > now.getTime()) continue;
|
||||
let label = row.label;
|
||||
if (row.origin === "COACH") {
|
||||
label = translateOrNull(t, row.label) ?? label;
|
||||
}
|
||||
preventiveDue.push({ label, staysDue });
|
||||
preventiveDue.push({ label, longCycle });
|
||||
}
|
||||
|
||||
// Group the two figures for the reader. `Intl.NumberFormat` is the only
|
||||
|
||||
@@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [
|
||||
"scoreSleep",
|
||||
"scoreStress",
|
||||
"scoreStrain",
|
||||
"bloodKetones", // #1367 — the chart on the ketones page.
|
||||
] as const;
|
||||
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores";
|
||||
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
|
||||
import { readDayBands, shapeDayValues } from "@/lib/day/values";
|
||||
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
|
||||
import {
|
||||
resolveDayReadings,
|
||||
type DayReadings,
|
||||
@@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
]);
|
||||
|
||||
const presentTypes = [...readings.rowsByType.keys()];
|
||||
// The index reveals the glucose it was built from, so it needs both
|
||||
// reading types visible, not only the ketones the day holds.
|
||||
const gkiReadable =
|
||||
readings.rowsByType.has("BLOOD_KETONES") &&
|
||||
typeVisible("BLOOD_KETONES") &&
|
||||
typeVisible("BLOOD_GLUCOSE");
|
||||
const glucoseKetoneIndex = gkiReadable
|
||||
? await readGlucoseKetoneIndex({
|
||||
userId: recordId,
|
||||
from: dayStart,
|
||||
to: dayEnd,
|
||||
})
|
||||
: [];
|
||||
const [bands, notable] =
|
||||
presentTypes.length === 0
|
||||
? [new Map(), []]
|
||||
@@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
counts: { values: values.length, entries: events.length },
|
||||
running,
|
||||
values,
|
||||
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
|
||||
events,
|
||||
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
|
||||
scores,
|
||||
|
||||
@@ -117,6 +117,24 @@ const dayValue = z
|
||||
})
|
||||
.meta({ id: "DayValue" });
|
||||
|
||||
/**
|
||||
* #1367 — one glucose-ketone index of the day, derived from a ketone reading
|
||||
* and the glucose reading paired with it; never stored.
|
||||
*/
|
||||
const dayGlucoseKetoneIndex = z
|
||||
.object({
|
||||
at: instant.describe("The ketone reading's instant."),
|
||||
value: z.number().describe("The index, to one decimal."),
|
||||
ketoneId: z.string().describe("The ketone reading's id."),
|
||||
glucoseId: z.string().describe("The paired glucose reading's id."),
|
||||
glucoseAt: instant.describe("The paired glucose reading's instant."),
|
||||
ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."),
|
||||
glucoseMmol: z
|
||||
.number()
|
||||
.describe("The paired glucose in mmol/L, one decimal."),
|
||||
})
|
||||
.meta({ id: "DayGlucoseKetoneIndex" });
|
||||
|
||||
const dayEvent = z
|
||||
.object({
|
||||
at: instant
|
||||
@@ -232,6 +250,12 @@ export const dayResponseSchema = z
|
||||
}),
|
||||
running: z.array(dayRunningItem),
|
||||
values: z.array(dayValue),
|
||||
glucoseKetoneIndex: z
|
||||
.array(dayGlucoseKetoneIndex)
|
||||
.optional()
|
||||
.describe(
|
||||
"#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.",
|
||||
),
|
||||
events: z.array(dayEvent),
|
||||
notable: z.array(dayNotable),
|
||||
scores: z
|
||||
|
||||
@@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-context glucose rows. Values are stored canonically in mg/dL and print
|
||||
* in the owner's unit, in the precision their readings carry.
|
||||
* The glucose group's own rows: one per meal context, stored canonically in
|
||||
* mg/dL and printed in the owner's unit in the precision their readings
|
||||
* carry, then the glucose-ketone index (#1367), a ratio without a unit.
|
||||
*/
|
||||
function glucoseContextTableRows(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
@@ -209,6 +210,17 @@ function glucoseContextTableRows(
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
const gki = data.glucoseKetoneIndex;
|
||||
if (gki) {
|
||||
rows.push([
|
||||
t("measurements.glucoseKetoneIndex"),
|
||||
num(gki.latest, 1),
|
||||
num(gki.avg, 1),
|
||||
num(gki.min, 1),
|
||||
num(gki.max, 1),
|
||||
String(gki.count),
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,13 @@ export interface DoctorReportData {
|
||||
* `stillLearning` when the period is too thin to assert clinically.
|
||||
*/
|
||||
glucoseClinical: GlucoseClinicalMetrics;
|
||||
/**
|
||||
* #1367 — the glucose-ketone index over the period (count, mean, range,
|
||||
* latest), derived at read time from each ketone reading and the glucose
|
||||
* reading paired with it. Null when there is none or when either reading
|
||||
* type is withheld. Optional so older fixtures still typecheck.
|
||||
*/
|
||||
glucoseKetoneIndex?: DoctorReportStats | null;
|
||||
bmi: number | null;
|
||||
/** Keyed by medication id; see `DoctorReportCompliance`. */
|
||||
compliance: Record<string, DoctorReportCompliance>;
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import { loadDenseMeasurementBuckets } from "./dense-buckets";
|
||||
import { summariseDenseBuckets } from "./measurement-series";
|
||||
import { emptyGlucoseClinical } from "./glucose-panel";
|
||||
import { readReportGki } from "./glucose-ketone-index";
|
||||
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
|
||||
import { loadReportWorkouts } from "./workouts";
|
||||
import { loadReportMedicationEffects } from "./medication-effects";
|
||||
@@ -249,9 +250,8 @@ export async function collectDoctorReportData(
|
||||
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Narrow select: the collector reads exactly these seven fields.
|
||||
// The full-width read pulled every column — including the encrypted
|
||||
// notes — across up to 730 days of rows.
|
||||
// Narrow select: the full-width read pulled every column, encrypted
|
||||
// notes included, across up to 730 days of rows.
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
@@ -739,6 +739,7 @@ export async function collectDoctorReportData(
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseClinical,
|
||||
glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end),
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
|
||||
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The glucose-ketone index in the doctor report (#1367): count, mean, range
|
||||
* and latest over the report window, read by the one pairing rule
|
||||
* (`read-glucose-ketone-index.ts`) so the report carries the numbers the
|
||||
* ketones page shows.
|
||||
*
|
||||
* Both leaves must admit it. The index is glucose over ketones, so beside the
|
||||
* ketone series it gives the glucose back exactly: a report that withholds
|
||||
* glucose withholds the index too.
|
||||
*/
|
||||
import type { DoctorReportStats } from "@/lib/doctor-report-types";
|
||||
import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index";
|
||||
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
|
||||
|
||||
import type { ReportGate } from "./selection-gate";
|
||||
|
||||
export async function readReportGki(
|
||||
gate: ReportGate,
|
||||
/** The report's own stats: no ketone row in the window, no read. */
|
||||
stats: Readonly<Record<string, DoctorReportStats>>,
|
||||
userId: string,
|
||||
/** The report window; `end` is inclusive, as the measurement read has it. */
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportStats | null> {
|
||||
if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) {
|
||||
return null;
|
||||
}
|
||||
if (!stats.BLOOD_KETONES?.count) return null;
|
||||
const points = await readGlucoseKetoneIndex({
|
||||
userId,
|
||||
from: start,
|
||||
to: new Date(end.getTime() + 1),
|
||||
});
|
||||
const summary = summariseGlucoseKetoneIndex(points);
|
||||
if (!summary) return null;
|
||||
return {
|
||||
avg: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
count: summary.count,
|
||||
latest: summary.latest,
|
||||
latestAt: points[points.length - 1]!.at,
|
||||
};
|
||||
}
|
||||
@@ -193,6 +193,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user