diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f74563ea..e2f98ebbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bundle-budget.json b/bundle-budget.json index 0b6a0f74cb..393864f803 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -58,6 +58,7 @@ "/insights/headphone-audio/page": 336, "/insights/health-score/page": 315, "/insights/hrv/page": 337, + "/insights/ketones/page": 339, "/insights/lean-body-mass/page": 336, "/insights/medications/page": 338, "/insights/mood/page": 337, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45964de7d4..dd15cb5604 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:`, 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: diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index 65d4c11b79..c59065e39e 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -163,6 +163,7 @@ hand-authored. | `RESPIRATORY_RATE` | count/min | | `OXYGEN_SATURATION` | percent (0–100) | | `BLOOD_GLUCOSE` | mg/dL | +| `BLOOD_KETONES` | mmol/L | | `BODY_FAT` | percent (0–100) | | `FAT_MASS` | kg | | `FAT_FREE_MASS` | kg | diff --git a/docs/using/cycle-calendar-feed.md b/docs/using/cycle-calendar-feed.md index b05c53d66d..3ddb6b29ac 100644 --- a/docs/using/cycle-calendar-feed.md +++ b/docs/using/cycle-calendar-feed.md @@ -32,16 +32,23 @@ managed on the web; the iOS app shows where to find it. ## What the feed shows - The next period and, once three cycles are logged, a few after it (three - by default, up to six). Each event covers the whole window the period may - fall in, and the window widens the further out it is. The list stops itself - where a date would be more guess than estimate (beyond about ±10 days, or - half a cycle). + by default, up to six). The range a period may fall in widens the further + out it is, and the list stops itself where a date would be more guess + than estimate (beyond about ±10 days, or half a cycle). How that range is + drawn is up to you, under "How forecasts look": + - **Expected days with margin** (the default for a new subscription): the + expected days as one event, and the days the period may start earlier + or run on later as separate "Period possible" events. + - **Expected days**: only the expected days, as one event. + - **One range**: one event covering the whole range. A subscription + turned on before v1.47.2 keeps this style until you change it, and + renewing the link keeps whichever style it has. - Optionally, your past cycles (up to twelve). - Optionally, when your goal is trying to conceive, the estimated fertile window of the next cycle. It is an estimate, not a contraceptive method. -Titles are discreet by default: each event is a dot and the calendar is called -"HealthLog". Descriptive titles name the period instead. Events never carry +Titles are discreet by default: a period is a dot (●), a possible day an open +dot (○), and the calendar is called "HealthLog". Descriptive titles name the period instead. Events never carry symptoms, notes or any other detail of your log. When a period is later than its window, the feed shows no forecast until you diff --git a/messages/de.json b/messages/de.json index 6d0139ccd0..4dcc47c518 100644 --- a/messages/de.json +++ b/messages/de.json @@ -813,6 +813,10 @@ "typeSleep": "Schlaf", "typeSteps": "Schritte", "typeBloodGlucose": "Blutzucker", + "typeBloodKetones": "Blutketone", + "glucoseKetoneIndex": "Glukose-Keton-Index", + "pairedGlucoseLabel": "Blutzucker aus derselben Messung ({unit})", + "pairedGlucoseHint": "Wird als eigener Blutzuckerwert mit derselben Uhrzeit gespeichert; aus dem Paar ergibt sich der Glukose-Keton-Index.", "typeTotalBodyWater": "Gesamtkörperwasser", "typeBoneMass": "Knochenmasse", "typeOxygenSaturation": "Sauerstoffsättigung", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glukose", + "navBloodKetones": "Ketone", + "bloodKetones": { + "title": "Blutketone", + "description": "Deine Beta-Hydroxybutyrat-Werte im Zeitverlauf, in {unit}.", + "chartTitle": "Blutketone", + "emptyState": { + "title": "Noch keine Ketonwerte", + "description": "Ketonwerte erscheinen hier, sobald du einen erfasst, allein oder mit dem Blutzucker aus demselben Fingerstich.", + "cta": "Ketone erfassen" + }, + "gki": { + "description": "Blutzucker geteilt durch Ketone, für jeden Ketonwert mit einem Blutzuckerwert innerhalb von 15 Minuten.", + "emptyTitle": "Noch kein Index", + "emptyDescription": "Er erscheint, sobald zu einem Ketonwert über null ein Blutzuckerwert zur selben Zeit oder innerhalb von 15 Minuten vorliegt.", + "latest": "Zuletzt {value}: Blutzucker {glucose} {unit} durch Ketone {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Gesamtkörperwasser", "description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.", @@ -10056,6 +10077,17 @@ "DISCREET": "Diskret (●)", "DESCRIPTIVE": "Beschreibend" }, + "forecasts": "Darstellung der Schätzung", + "forecastStyle": { + "EXACT": "Nur erwartete Tage", + "MARGIN": "Erwartete Tage mit Spielraum", + "BAND": "Ein Zeitraum" + }, + "forecastStyleHint": { + "EXACT": "Nur die erwarteten Tage.", + "MARGIN": "Die erwarteten Tage, dazu als eigene Termine die Tage davor und danach, falls die Periode früher oder später beginnt.", + "BAND": "Ein Termin über alle Tage, auf die die Periode fallen kann." + }, "fertile": "Fruchtbares Fenster", "fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.", "rotate": "Link erneuern", @@ -10075,6 +10107,7 @@ "periodEstimate": "Periode (Schätzung)", "periodLogged": "Periode (erfasst)", "fertileEstimate": "Fruchtbares Fenster (Schätzung)", + "periodPossible": "Periode möglich", "likelyStart": "Wahrscheinlicher Beginn {date}, {band}", "bandOne": "±{count} Tag", "bandFew": "±{count} Tage", @@ -10584,7 +10617,9 @@ "openExpanded": "Alle", "openFlagged": "Nur außerhalb des Bereichs", "openCollapsed": "Keine", - "orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie." + "orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie.", + "orderHeading": "Reihenfolge der Kategorien", + "orderDefault": "Bis du eine verschiebst, stehen eingebaute Kategorien in fester Folge, deine eigenen alphabetisch und {other} zuletzt." }, "sourceRange": { "fromReport": "Befundbereich {range}", @@ -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", diff --git a/messages/en.json b/messages/en.json index 9e86a3f69f..6a5c05ac95 100644 --- a/messages/en.json +++ b/messages/en.json @@ -813,6 +813,10 @@ "typeSleep": "Sleep", "typeSteps": "Steps", "typeBloodGlucose": "Blood glucose", + "typeBloodKetones": "Blood ketones", + "glucoseKetoneIndex": "Glucose-ketone index", + "pairedGlucoseLabel": "Glucose from the same reading ({unit})", + "pairedGlucoseHint": "Saved as its own glucose reading at the same time; the glucose-ketone index is worked out from the pair.", "typeTotalBodyWater": "Total body water", "typeBoneMass": "Bone mass", "typeOxygenSaturation": "Oxygen saturation", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Ketones", + "bloodKetones": { + "title": "Blood Ketones", + "description": "Your beta-hydroxybutyrate readings over time, in {unit}.", + "chartTitle": "Blood ketones", + "emptyState": { + "title": "No ketone readings yet", + "description": "Ketone readings appear here once you log one, on its own or with the glucose from the same fingerstick.", + "cta": "Log ketones" + }, + "gki": { + "description": "Glucose divided by ketones, for each ketone reading with a glucose reading within 15 minutes.", + "emptyTitle": "No index yet", + "emptyDescription": "It appears once a ketone reading above zero has a glucose reading taken at the same time or within 15 minutes.", + "latest": "Latest {value}: glucose {glucose} {unit} over ketones {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Total Body Water", "description": "Your total body water over time, in kilograms.", @@ -10056,6 +10077,17 @@ "DISCREET": "Discreet (●)", "DESCRIPTIVE": "Descriptive" }, + "forecasts": "How forecasts look", + "forecastStyle": { + "EXACT": "Expected days", + "MARGIN": "Expected days with margin", + "BAND": "One range" + }, + "forecastStyleHint": { + "EXACT": "Just the expected days.", + "MARGIN": "Expected days, with the days 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", diff --git a/messages/es.json b/messages/es.json index b20934c4df..a2b612badb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -813,6 +813,10 @@ "typeSleep": "Sueño", "typeSteps": "Pasos", "typeBloodGlucose": "Glucemia", + "typeBloodKetones": "Cetonas en sangre", + "glucoseKetoneIndex": "Índice glucosa-cetonas", + "pairedGlucoseLabel": "Glucosa de la misma medición ({unit})", + "pairedGlucoseHint": "Se guarda como una lectura de glucosa propia con la misma hora; el índice glucosa-cetonas se calcula a partir del par.", "typeTotalBodyWater": "Agua corporal total", "typeBoneMass": "Masa ósea", "typeOxygenSaturation": "Saturación de oxígeno", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucosa", + "navBloodKetones": "Cetonas", + "bloodKetones": { + "title": "Cetonas en sangre", + "description": "Tus lecturas de beta-hidroxibutirato en el tiempo, en {unit}.", + "chartTitle": "Cetonas en sangre", + "emptyState": { + "title": "Aún no hay lecturas de cetonas", + "description": "Las lecturas de cetonas aparecen aquí cuando registras una, sola o con la glucosa del mismo pinchazo.", + "cta": "Registrar cetonas" + }, + "gki": { + "description": "Glucosa dividida por cetonas, para cada lectura de cetonas con una de glucosa en un margen de 15 minutos.", + "emptyTitle": "Aún no hay índice", + "emptyDescription": "Aparece cuando una lectura de cetonas mayor que cero tiene una de glucosa tomada a la misma hora o en un margen de 15 minutos.", + "latest": "Último {value}: glucosa {glucose} {unit} entre cetonas {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Agua corporal total", "description": "Tu agua corporal total en el tiempo, en kilogramos.", @@ -10056,6 +10077,17 @@ "DISCREET": "Discreto (●)", "DESCRIPTIVE": "Descriptivo" }, + "forecasts": "Cómo se ven las previsiones", + "forecastStyle": { + "EXACT": "Días previstos", + "MARGIN": "Días previstos y margen", + "BAND": "Un solo intervalo" + }, + "forecastStyleHint": { + "EXACT": "Solo los días previstos.", + "MARGIN": "Los días previstos, con los días en que 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", diff --git a/messages/fr.json b/messages/fr.json index 24dba34925..af97df0368 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -813,6 +813,10 @@ "typeSleep": "Sommeil", "typeSteps": "Pas", "typeBloodGlucose": "Glycémie", + "typeBloodKetones": "Cétones sanguines", + "glucoseKetoneIndex": "Indice glucose-cétones", + "pairedGlucoseLabel": "Glycémie de la même mesure ({unit})", + "pairedGlucoseHint": "Enregistrée comme un relevé de glycémie à la même heure ; l’indice glucose-cétones est calculé à partir de la paire.", "typeTotalBodyWater": "Eau corporelle totale", "typeBoneMass": "Masse osseuse", "typeOxygenSaturation": "Saturation en oxygène", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Cétones", + "bloodKetones": { + "title": "Cétones sanguines", + "description": "Vos relevés de bêta-hydroxybutyrate au fil du temps, en {unit}.", + "chartTitle": "Cétones sanguines", + "emptyState": { + "title": "Pas encore de relevé de cétones", + "description": "Les relevés de cétones apparaissent ici dès que vous en saisissez un, seul ou avec la glycémie de la même piqûre.", + "cta": "Enregistrer les cétones" + }, + "gki": { + "description": "Glycémie divisée par les cétones, pour chaque relevé de cétones accompagné d’une glycémie à 15 minutes près.", + "emptyTitle": "Pas encore d’indice", + "emptyDescription": "Il apparaît dès qu’un relevé de cétones supérieur à zéro a une glycémie prise au même moment ou à 15 minutes près.", + "latest": "Dernier {value} : glycémie {glucose} {unit} sur cétones {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Eau corporelle totale", "description": "Votre eau corporelle totale au fil du temps, en kilogrammes.", @@ -10056,6 +10077,17 @@ "DISCREET": "Discret (●)", "DESCRIPTIVE": "Explicite" }, + "forecasts": "Affichage des prévisions", + "forecastStyle": { + "EXACT": "Jours prévus", + "MARGIN": "Jours prévus et marge", + "BAND": "Une seule plage" + }, + "forecastStyleHint": { + "EXACT": "Seulement les jours prévus.", + "MARGIN": "Les jours prévus, avec en événements séparés les jours où les règles peuvent commencer plus tôt ou plus tard.", + "BAND": "Un seul événement couvrant tous les jours où 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", diff --git a/messages/it.json b/messages/it.json index f0c0734a4e..fed51449c3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -813,6 +813,10 @@ "typeSleep": "Sonno", "typeSteps": "Passi", "typeBloodGlucose": "Glicemia", + "typeBloodKetones": "Chetoni nel sangue", + "glucoseKetoneIndex": "Indice glucosio-chetoni", + "pairedGlucoseLabel": "Glicemia della stessa misurazione ({unit})", + "pairedGlucoseHint": "Salvata come lettura di glicemia a sé con lo stesso orario; l’indice glucosio-chetoni si ricava dalla coppia.", "typeTotalBodyWater": "Acqua corporea totale", "typeBoneMass": "Massa ossea", "typeOxygenSaturation": "Saturazione di ossigeno", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucosio", + "navBloodKetones": "Chetoni", + "bloodKetones": { + "title": "Chetoni nel sangue", + "description": "Le tue letture di beta-idrossibutirrato nel tempo, in {unit}.", + "chartTitle": "Chetoni nel sangue", + "emptyState": { + "title": "Ancora nessuna lettura di chetoni", + "description": "Le letture di chetoni appaiono qui quando ne registri una, da sola o con la glicemia della stessa puntura.", + "cta": "Registra i chetoni" + }, + "gki": { + "description": "Glicemia divisa per i chetoni, per ogni lettura di chetoni con una glicemia entro 15 minuti.", + "emptyTitle": "Ancora nessun indice", + "emptyDescription": "Compare quando una lettura di chetoni superiore a zero ha una glicemia presa allo stesso momento o entro 15 minuti.", + "latest": "Ultimo {value}: glicemia {glucose} {unit} su chetoni {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Acqua corporea totale", "description": "La tua acqua corporea totale nel tempo, in chilogrammi.", @@ -10056,6 +10077,17 @@ "DISCREET": "Discreto (●)", "DESCRIPTIVE": "Descrittivo" }, + "forecasts": "Aspetto delle previsioni", + "forecastStyle": { + "EXACT": "Giorni previsti", + "MARGIN": "Giorni previsti e margine", + "BAND": "Un unico intervallo" + }, + "forecastStyleHint": { + "EXACT": "Solo i giorni previsti.", + "MARGIN": "I giorni previsti, con i giorni in cui 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", diff --git a/messages/ko.json b/messages/ko.json index f8dfaefe15..77eee65e5c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -813,6 +813,10 @@ "typeSleep": "수면", "typeSteps": "걸음 수", "typeBloodGlucose": "혈당", + "typeBloodKetones": "혈중 케톤", + "glucoseKetoneIndex": "포도당-케톤 지수", + "pairedGlucoseLabel": "같은 측정의 혈당 ({unit})", + "pairedGlucoseHint": "같은 시각의 별도 혈당 기록으로 저장되고, 두 값으로 포도당-케톤 지수를 계산해요.", "typeTotalBodyWater": "체수분", "typeBoneMass": "골량", "typeOxygenSaturation": "산소포화도", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "혈당", + "navBloodKetones": "케톤", + "bloodKetones": { + "title": "혈중 케톤", + "description": "시간에 따른 베타-하이드록시부티르산 측정값이에요({unit}).", + "chartTitle": "혈중 케톤", + "emptyState": { + "title": "아직 케톤 측정값이 없어요", + "description": "케톤을 단독으로 또는 같은 채혈의 혈당과 함께 기록하면 여기에 측정값이 나타나요.", + "cta": "케톤 기록" + }, + "gki": { + "description": "15분 이내에 혈당 측정값이 있는 케톤 측정마다 혈당을 케톤으로 나눈 값이에요.", + "emptyTitle": "아직 지수가 없어요", + "emptyDescription": "0보다 큰 케톤 측정과 같은 시각이나 15분 이내에 잰 혈당이 있으면 나타나요.", + "latest": "최근 {value}: 혈당 {glucose} {unit} ÷ 케톤 {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "총 체수분", "description": "시간에 따른 총 체수분이에요(kg).", @@ -10056,6 +10077,17 @@ "DISCREET": "드러나지 않게 (●)", "DESCRIPTIVE": "내용 표시" }, + "forecasts": "예측 표시 방식", + "forecastStyle": { + "EXACT": "예상일만", + "MARGIN": "예상일과 여유 기간", + "BAND": "하나의 기간" + }, + "forecastStyleHint": { + "EXACT": "예상되는 날만 보여줘요.", + "MARGIN": "예상일과 함께, 더 일찍 또는 늦게 시작할 수 있는 날을 별도 일정으로 보여줘요.", + "BAND": "생리가 있을 수 있는 모든 날을 하나의 일정으로 보여줘요." + }, "fertile": "가임기", "fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.", "rotate": "링크 다시 발급", @@ -10075,6 +10107,7 @@ "periodEstimate": "생리 (예상)", "periodLogged": "생리 (기록)", "fertileEstimate": "가임기 (예상)", + "periodPossible": "생리 가능", "likelyStart": "가장 가능성 높은 시작일 {date}, {band}", "bandOne": "±{count}일", "bandFew": "±{count}일", @@ -10584,7 +10617,9 @@ "openExpanded": "모두", "openFlagged": "범위 밖만", "openCollapsed": "없음", - "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요." + "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요.", + "orderHeading": "카테고리 순서", + "orderDefault": "직접 옮기기 전에는 기본 카테고리가 정해진 순서로, 직접 만든 카테고리가 가나다순으로 이어지고 {other}가 마지막에 와요." }, "sourceRange": { "fromReport": "결과지 범위 {range}", @@ -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}개", diff --git a/messages/nl.json b/messages/nl.json index 462f15c053..1df8e98bfe 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -813,6 +813,10 @@ "typeSleep": "Slaap", "typeSteps": "Stappen", "typeBloodGlucose": "Bloedglucose", + "typeBloodKetones": "Bloedketonen", + "glucoseKetoneIndex": "Glucose-ketonindex", + "pairedGlucoseLabel": "Glucose uit dezelfde meting ({unit})", + "pairedGlucoseHint": "Wordt als eigen glucosemeting met hetzelfde tijdstip opgeslagen; uit het paar volgt de glucose-ketonindex.", "typeTotalBodyWater": "Totaal lichaamswater", "typeBoneMass": "Botmassa", "typeOxygenSaturation": "Zuurstofsaturatie", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glucose", + "navBloodKetones": "Ketonen", + "bloodKetones": { + "title": "Bloedketonen", + "description": "Je bèta-hydroxybutyraatmetingen in de loop van de tijd, in {unit}.", + "chartTitle": "Bloedketonen", + "emptyState": { + "title": "Nog geen ketonmetingen", + "description": "Ketonmetingen verschijnen hier zodra je er een vastlegt, los of met de glucose uit dezelfde vingerprik.", + "cta": "Ketonen vastleggen" + }, + "gki": { + "description": "Glucose gedeeld door ketonen, voor elke ketonmeting met een glucosemeting binnen 15 minuten.", + "emptyTitle": "Nog geen index", + "emptyDescription": "Hij verschijnt zodra een ketonmeting boven nul een glucosemeting op hetzelfde moment of binnen 15 minuten heeft.", + "latest": "Laatst {value}: glucose {glucose} {unit} gedeeld door ketonen {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Totaal lichaamswater", "description": "Je totale lichaamswater in de loop van de tijd, in kilogram.", @@ -10056,6 +10077,17 @@ "DISCREET": "Discreet (●)", "DESCRIPTIVE": "Beschrijvend" }, + "forecasts": "Weergave van voorspellingen", + "forecastStyle": { + "EXACT": "Verwachte dagen", + "MARGIN": "Verwachte dagen met marge", + "BAND": "Eén bereik" + }, + "forecastStyleHint": { + "EXACT": "Alleen de verwachte dagen.", + "MARGIN": "De verwachte dagen, met de dagen waarop 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", diff --git a/messages/pl.json b/messages/pl.json index f83b583632..fa9457d777 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -813,6 +813,10 @@ "typeSleep": "Sen", "typeSteps": "Kroki", "typeBloodGlucose": "Glikemia", + "typeBloodKetones": "Ketony we krwi", + "glucoseKetoneIndex": "Indeks glukozowo-ketonowy", + "pairedGlucoseLabel": "Glukoza z tego samego pomiaru ({unit})", + "pairedGlucoseHint": "Zapisywana jako osobny odczyt glukozy z tą samą godziną; z tej pary obliczany jest indeks glukozowo-ketonowy.", "typeTotalBodyWater": "Całkowita woda w organizmie", "typeBoneMass": "Masa kostna", "typeOxygenSaturation": "Saturacja tlenem", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glukoza", + "navBloodKetones": "Ketony", + "bloodKetones": { + "title": "Ketony we krwi", + "description": "Twoje odczyty beta-hydroksymaślanu w czasie, w {unit}.", + "chartTitle": "Ketony we krwi", + "emptyState": { + "title": "Brak jeszcze odczytów ketonów", + "description": "Odczyty ketonów pojawią się tutaj po zapisaniu jednego, osobno lub z glukozą z tego samego nakłucia.", + "cta": "Zapisz ketony" + }, + "gki": { + "description": "Glukoza podzielona przez ketony, dla każdego odczytu ketonów z odczytem glukozy w ciągu 15 minut.", + "emptyTitle": "Brak jeszcze indeksu", + "emptyDescription": "Pojawi się, gdy do odczytu ketonów powyżej zera będzie odczyt glukozy z tej samej chwili lub w ciągu 15 minut.", + "latest": "Ostatnio {value}: glukoza {glucose} {unit} przez ketony {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Całkowita woda w organizmie", "description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.", @@ -10056,6 +10077,17 @@ "DISCREET": "Dyskretne (●)", "DESCRIPTIVE": "Opisowe" }, + "forecasts": "Wygląd prognoz", + "forecastStyle": { + "EXACT": "Przewidywane dni", + "MARGIN": "Przewidywane dni z marginesem", + "BAND": "Jeden zakres" + }, + "forecastStyleHint": { + "EXACT": "Tylko przewidywane dni.", + "MARGIN": "Przewidywane dni, a jako osobne wydarzenia dni, w które miesiączka może zacząć się wcześniej lub później.", + "BAND": "Jedno wydarzenie obejmujące wszystkie dni, na które może przypaść 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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 97989a5544..6abf9a204e 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -813,6 +813,10 @@ "typeSleep": "Sono", "typeSteps": "Passos", "typeBloodGlucose": "Glicemia", + "typeBloodKetones": "Cetonas no sangue", + "glucoseKetoneIndex": "Índice glicose-cetona", + "pairedGlucoseLabel": "Glicose da mesma medição ({unit})", + "pairedGlucoseHint": "Salva como uma leitura de glicose própria no mesmo horário; o índice glicose-cetona é calculado a partir do par.", "typeTotalBodyWater": "Água corporal total", "typeBoneMass": "Massa óssea", "typeOxygenSaturation": "Saturação de oxigênio", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Glicose", + "navBloodKetones": "Cetonas", + "bloodKetones": { + "title": "Cetonas no sangue", + "description": "Suas leituras de beta-hidroxibutirato ao longo do tempo, em {unit}.", + "chartTitle": "Cetonas no sangue", + "emptyState": { + "title": "Ainda não há leituras de cetonas", + "description": "As leituras de cetonas aparecem aqui assim que você registra uma, sozinha ou com a glicose da mesma picada.", + "cta": "Registrar cetonas" + }, + "gki": { + "description": "Glicose dividida pelas cetonas, para cada leitura de cetonas com uma de glicose em até 15 minutos.", + "emptyTitle": "Ainda não há índice", + "emptyDescription": "Ele aparece quando uma leitura de cetonas acima de zero tem uma de glicose feita no mesmo horário ou em até 15 minutos.", + "latest": "Último {value}: glicose {glucose} {unit} sobre cetonas {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Água Corporal Total", "description": "Sua água corporal total ao longo do tempo, em quilogramas.", @@ -10056,6 +10077,17 @@ "DISCREET": "Discreto (●)", "DESCRIPTIVE": "Descritivo" }, + "forecasts": "Como as previsões aparecem", + "forecastStyle": { + "EXACT": "Dias previstos", + "MARGIN": "Dias previstos e margem", + "BAND": "Um único intervalo" + }, + "forecastStyleHint": { + "EXACT": "Só os dias previstos.", + "MARGIN": "Os dias previstos, com os dias em que 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", diff --git a/messages/ru.json b/messages/ru.json index b83a16b5a5..159cf4fabe 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -813,6 +813,10 @@ "typeSleep": "Сон", "typeSteps": "Шаги", "typeBloodGlucose": "Глюкоза крови", + "typeBloodKetones": "Кетоны в крови", + "glucoseKetoneIndex": "Глюкозо-кетоновый индекс", + "pairedGlucoseLabel": "Глюкоза из того же измерения ({unit})", + "pairedGlucoseHint": "Сохраняется как отдельное измерение глюкозы с тем же временем; по этой паре рассчитывается глюкозо-кетоновый индекс.", "typeTotalBodyWater": "Общая вода в организме", "typeBoneMass": "Костная масса", "typeOxygenSaturation": "Насыщение кислородом", @@ -4002,6 +4006,23 @@ } }, "navBloodGlucose": "Глюкоза", + "navBloodKetones": "Кетоны", + "bloodKetones": { + "title": "Кетоны в крови", + "description": "Ваши измерения бета-гидроксибутирата в динамике, в {unit}.", + "chartTitle": "Кетоны в крови", + "emptyState": { + "title": "Измерений кетонов пока нет", + "description": "Измерения кетонов появятся здесь, как только вы запишете одно, отдельно или вместе с глюкозой из того же прокола.", + "cta": "Записать кетоны" + }, + "gki": { + "description": "Глюкоза, делённая на кетоны, для каждого измерения кетонов с измерением глюкозы в пределах 15 минут.", + "emptyTitle": "Индекса пока нет", + "emptyDescription": "Он появится, когда к измерению кетонов выше нуля будет измерение глюкозы в то же время или в пределах 15 минут.", + "latest": "Последний {value}: глюкоза {glucose} {unit} на кетоны {ketones} {unit}, {date}." + } + }, "totalBodyWater": { "title": "Общая вода в организме", "description": "Ваша общая вода в организме в динамике, в килограммах.", @@ -10056,6 +10077,17 @@ "DISCREET": "Незаметно (●)", "DESCRIPTIVE": "Подробно" }, + "forecasts": "Вид прогноза", + "forecastStyle": { + "EXACT": "Ожидаемые дни", + "MARGIN": "Ожидаемые дни с запасом", + "BAND": "Один промежуток" + }, + "forecastStyleHint": { + "EXACT": "Только ожидаемые дни.", + "MARGIN": "Ожидаемые дни, а отдельными событиями дни, когда месячные могут начаться раньше или позже.", + "BAND": "Одно событие на все дни, на которые могут прийтись месячные." + }, "fertile": "Фертильное окно", "fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.", "rotate": "Обновить ссылку", @@ -10075,6 +10107,7 @@ "periodEstimate": "Месячные (оценка)", "periodLogged": "Месячные (записаны)", "fertileEstimate": "Фертильное окно (оценка)", + "periodPossible": "Возможны месячные", "likelyStart": "Наиболее вероятное начало {date}, {band}", "bandOne": "±{count} день", "bandFew": "±{count} дня", @@ -10584,7 +10617,9 @@ "openExpanded": "Все", "openFlagged": "Только вне диапазона", "openCollapsed": "Ни одной", - "orderWithinGroups": "Порядок действует внутри каждой категории." + "orderWithinGroups": "Порядок действует внутри каждой категории.", + "orderHeading": "Порядок категорий", + "orderDefault": "Пока вы ничего не переместили, встроенные категории идут в постоянном порядке, ваши собственные по алфавиту, а {other} в конце." }, "sourceRange": { "fromReport": "Диапазон из отчёта {range}", @@ -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}", diff --git a/package.json b/package.json index fad8a207ec..2713cdc570 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52e9fed649..f1d037f378 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0d63e5b6d9..24ae01c437 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql b/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql new file mode 100644 index 0000000000..6e75584386 --- /dev/null +++ b/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql @@ -0,0 +1,33 @@ +-- How the cycle calendar feed draws a forecast period (#1350): +-- +-- EXACT the calculated period only, no margin +-- MARGIN the calculated period, plus the days it may start earlier or end +-- later as separate "possible" events +-- BAND one event covering every day the period may fall on +-- +-- The column defaults to BAND, which is what every feed drew until now, so an +-- existing subscription keeps exactly the events it had. A new subscription is +-- given MARGIN by the route that mints it, not by this default. +-- +-- One DO block, so the file is one statement that rolls back whole and the +-- lock wait on `cycle_calendar_feeds` is bounded. The type is created only when +-- missing and the column only when missing, so a replay is harmless. +-- Reversibility: dropping the column and the type returns every feed to the +-- single-band forecast. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_forecast_style' + ) THEN + CREATE TYPE "cycle_calendar_feed_forecast_style" AS ENUM ('EXACT', 'MARGIN', 'BAND'); + END IF; + + ALTER TABLE "cycle_calendar_feeds" + ADD COLUMN IF NOT EXISTS "forecast_style" "cycle_calendar_feed_forecast_style" NOT NULL DEFAULT 'BAND'; +END +$migration$; diff --git a/prisma/migrations/0496_blood_ketones/migration.sql b/prisma/migrations/0496_blood_ketones/migration.sql new file mode 100644 index 0000000000..c0d43bbbc5 --- /dev/null +++ b/prisma/migrations/0496_blood_ketones/migration.sql @@ -0,0 +1,12 @@ +-- Extend `measurement_type` with blood ketones (#1367): +-- +-- BLOOD_KETONES <- beta-hydroxybutyrate from a blood ketone meter (mmol/L) +-- +-- Purely additive: no existing row carries the value, and nothing in this +-- file uses it, so the rule that a value added in a transaction cannot be used +-- in that same transaction never comes into play. IF NOT EXISTS keeps a replay +-- on a partially applied database harmless. Enum values cannot be dropped in +-- PostgreSQL, so there is no down path. The glucose-ketone index is computed +-- at read time and has no column. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_KETONES'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8b102b41c0..80f3d59628 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1816,6 +1816,10 @@ enum MeasurementType { // v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED, // never folded into it and never summed with it into a total. BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM. + // v1.47.2 — blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick + // meter, typed by hand. The glucose-ketone index is derived from this and + // BLOOD_GLUCOSE at read time and never stored. + BLOOD_KETONES // mmol/L — capillary or venous beta-hydroxybutyrate (BHB); spot reading, never summed. @@map("measurement_type") } @@ -8343,6 +8347,17 @@ enum CycleCalendarFeedTitleMode { @@map("cycle_calendar_feed_title_mode") } +/// #1350 — how the feed draws a forecast period. EXACT is the calculated +/// period alone; MARGIN adds the days it may start earlier or end later as +/// separate events; BAND is one event over every day it may fall on. +enum CycleCalendarFeedForecastStyle { + EXACT + MARGIN + BAND + + @@map("cycle_calendar_feed_forecast_style") +} + /// #1350 — a subscribable calendar feed of one record's cycle forecast. /// /// The row is a URL credential. Only the HMAC of the raw `hlf_` token is @@ -8389,12 +8404,15 @@ model CycleCalendarFeed { /// Forecast cycles ahead (1..6). The feed caps itself further once the /// band grows past ±10 days or half a cycle. - horizonCycles Int @default(3) @map("horizon_cycles") + horizonCycles Int @default(3) @map("horizon_cycles") /// Observed cycles included as history (0..12). - historyCycles Int @default(0) @map("history_cycles") + historyCycles Int @default(0) @map("history_cycles") /// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only. - includeFertile Boolean @default(false) @map("include_fertile") - titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode") + includeFertile Boolean @default(false) @map("include_fertile") + titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode") + /// BAND by default so a feed minted before the option keeps its events. A + /// new feed is minted with MARGIN by the subscription route. + forecastStyle CycleCalendarFeedForecastStyle @default(BAND) @map("forecast_style") user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade) createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade) diff --git a/public/sw.js b/public/sw.js index 77dec3db21..19178159ed 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/scripts/__tests__/generate-sw-version.test.ts b/scripts/__tests__/generate-sw-version.test.ts index e123c8a15d..0df28757f8 100644 --- a/scripts/__tests__/generate-sw-version.test.ts +++ b/scripts/__tests__/generate-sw-version.test.ts @@ -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, + ); }); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1770cb6557..e6d9b833e5 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -11,7 +11,7 @@ "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, - "components/insights/insights-tab-strip.tsx": 1086, + "components/insights/insights-tab-strip.tsx": 1087, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1157, "components/medications/medication-table.tsx": 853, @@ -39,7 +39,7 @@ "lib/daily/digest.ts": 1317, "lib/daily/load-digest.ts": 1000, "lib/daily/today-overview.ts": 838, - "lib/dashboard-layout.ts": 976, + "lib/dashboard-layout.ts": 977, "lib/dashboard/snapshot.ts": 1550, "lib/day/records.ts": 1174, "lib/export/backup-plan.ts": 838, diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index adf8c6b014..20b5602547 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 523, - "total": 5476, + "total": 5475, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -440,7 +440,7 @@ "lib/jobs/__tests__/insight-status-generate.test.ts": 2, "lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2, "lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2, - "lib/jobs/__tests__/measurement-reminder.test.ts": 33, + "lib/jobs/__tests__/measurement-reminder.test.ts": 32, "lib/jobs/__tests__/medication-intake-import.test.ts": 1, "lib/jobs/__tests__/medication-reminder-check.test.ts": 27, "lib/jobs/__tests__/mood-reminder.test.ts": 15, diff --git a/src/__tests__/cycle-feed-content-guard.test.ts b/src/__tests__/cycle-feed-content-guard.test.ts index d250d0116d..83b9381386 100644 --- a/src/__tests__/cycle-feed-content-guard.test.ts +++ b/src/__tests__/cycle-feed-content-guard.test.ts @@ -118,13 +118,20 @@ beforeEach(() => { }); describe("the calendar feed body", () => { - it.each(["DISCREET", "DESCRIPTIVE"] as const)( - "carries no day-log content in %s mode", - async (titleMode) => { + it.each( + (["DISCREET", "DESCRIPTIVE"] as const).flatMap((titleMode) => + (["EXACT", "MARGIN", "BAND"] as const).map( + (forecastStyle) => [titleMode, forecastStyle] as const, + ), + ), + )( + "carries no day-log content in %s mode, %s forecasts", + async (titleMode, forecastStyle) => { const built = await buildCycleFeed( { ...FEED, titleMode, + forecastStyle, historyCycles: 12, horizonCycles: 6, includeFertile: true, @@ -146,6 +153,7 @@ describe("the calendar feed body", () => { { ...FEED, titleMode: "DESCRIPTIVE", + forecastStyle: "MARGIN", historyCycles: 6, includeFertile: true, }, diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index dc52ec8a10..1ddba1dcee 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -149,6 +149,8 @@ const ALLOWED_SQL: Record = { "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", "lib/dashboard/summary-reads.ts::1": "Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.", + "lib/measurements/read-glucose-ketone-index.ts::1": + "Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.", "lib/dashboard/summary-reads.ts::2": "Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.", }; diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 52bf079268..8dfdae6b60 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(111); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91); + expect(ALL_LEAF_IDS.length).toBe(112); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20); }); diff --git a/src/__tests__/labs-overview-response-consumer-guard.test.ts b/src/__tests__/labs-overview-response-consumer-guard.test.ts index 5f5e5cc2bf..bbd9ddc954 100644 --- a/src/__tests__/labs-overview-response-consumer-guard.test.ts +++ b/src/__tests__/labs-overview-response-consumer-guard.test.ts @@ -93,9 +93,10 @@ const READERS: Record = { 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); + }); }); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index f899a5e6b5..4c5c12a338 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/labs/overview/route.ts": { domain: "labs", - why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.", + why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.", }, "app/api/biomarkers/route.ts": { domain: "labs", diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 79c26c6fd1..e2d36f53b6 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "the onboarding unit picker: its options are the two units", }, "components/measurements/measurement-form.tsx": { - lines: 11, + lines: 12, reason: "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", }, @@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "the legacy export endpoint's canonical storage-unit contract", }, "lib/measurements/series/kinds.ts": { - lines: 4, + lines: 5, reason: - "canonical wire units; glucose is overridden to the reader's unit per request", + "canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice", }, "lib/doctor-report/stat-display.ts": { lines: 3, diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 9b62851c99..755f171eaf 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -4,11 +4,15 @@ * * One transaction: the live feed is revoked and its successor minted with the * same options, so there is never a moment with two live links or with none. + * The forecast style is copied too: a feed minted before that option keeps + * BAND rather than taking a new feed's default. * Calendars holding the old link stop updating on their next poll. The new - * URL is returned exactly once. Two rotations racing both revoke the same - * feed, and the second successor then meets the one-live-feed index: that - * one gets the mint path's 409 `cycle.feed.exists`, and the winner's link - * stands. + * URL is returned exactly once. Of two rotations racing, the second finds + * the feed already revoked (or its successor meets the one-live-feed index) + * and gets the mint path's 409 `cycle.feed.exists`; the winner's link + * stands. A subscription turned off during a rotation stays off, whichever + * commits first, and the rotation then answers 404 `cycle.feed.none`; an + * option changed during one lands on the link that stays live. * * Same gates as turning the subscription on: cookie session only, the owner or * the guardian of a managed profile (`requireGuardianAuth`), a fresh proof, @@ -33,7 +37,11 @@ import { feedLinks, noLiveFeed, } from "@/lib/cycle/feed/manage"; -import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status"; +import { + FEED_SUMMARY_SELECT, + findLiveFeed, + toFeedSummary, +} from "@/lib/cycle/feed/status"; import { mintFeedToken } from "@/lib/cycle/feed/token"; import { prisma } from "@/lib/db"; import { resolveOrigin } from "@/lib/http/origin"; @@ -70,21 +78,32 @@ export const POST = apiHandler(async (request: NextRequest) => { const minted = mintFeedToken(); const rotated = await prisma .$transaction(async (tx) => { - const live = await tx.cycleCalendarFeed.findFirst({ + const found = await tx.cycleCalendarFeed.findFirst({ where: { userId: user.id, revokedAt: null }, + select: { id: true }, + }); + if (!found) return null; + // Only while it is still live: a subscription turned off after the + // read above stays off instead of coming back under a new link. + const revoked = await tx.cycleCalendarFeed.updateMany({ + where: { id: found.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (revoked.count === 0) return "raced" as const; + // The options are read after the revoke holds the row, so a change + // that committed in between is the one the new link carries, and one + // that comes later waits and then finds the new link. + const live = await tx.cycleCalendarFeed.findUniqueOrThrow({ + where: { id: found.id }, select: { id: true, horizonCycles: true, historyCycles: true, includeFertile: true, titleMode: true, + forecastStyle: true, }, }); - if (!live) return null; - await tx.cycleCalendarFeed.update({ - where: { id: live.id }, - data: { revokedAt: new Date() }, - }); const created = await tx.cycleCalendarFeed.create({ data: { userId: user.id, @@ -95,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { historyCycles: live.historyCycles, includeFertile: live.includeFertile, titleMode: live.titleMode, + forecastStyle: live.forecastStyle, }, select: FEED_SUMMARY_SELECT, }); @@ -113,7 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => { if (isP2002(err)) return "raced" as const; throw err; }); - if (rotated === "raced") return feedExists(); + // Lost to another rotation, the link that won stands; lost to the + // subscription being turned off, there is none. + if (rotated === "raced") { + return (await findLiveFeed(user.id)) ? feedExists() : noLiveFeed(); + } if (!rotated) return noLiveFeed(); return apiSuccess( diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 8b8b3aba99..0acb4c30d5 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -59,7 +59,10 @@ import { destroyedDetails, overwriteDetails, } from "@/lib/sharing/audit-details"; -import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed"; +import { + NEW_FEED_FORECAST_STYLE, + cycleFeedOptionsPatchSchema, +} from "@/lib/validations/cycle-feed"; export const GET = apiHandler(async () => { const auth = await requireGuardianAuth(); @@ -110,6 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { userId: user.id, createdById: actor.id, + forecastStyle: NEW_FEED_FORECAST_STYLE, tokenHash: minted.hash, tokenPrefix: minted.prefix, }, @@ -166,7 +170,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ); } - const live = await findLiveFeed(user.id); + let live = await findLiveFeed(user.id); if (!live) return noLiveFeed(); const data = { @@ -180,10 +184,29 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ? { includeFertile: input.includeFertile } : {}), ...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}), + ...(input.forecastStyle !== undefined + ? { forecastStyle: input.forecastStyle } + : {}), }; - const updated = await prisma.cycleCalendarFeed.update({ - where: { id: live.id }, + // Only while the feed is still the live one. A rotation that committed + // after the read above revoked it and copied its old options onto the new + // link; the change then goes to that link, once. Updating the revoked row + // would answer 200 with the old options left in force. + let written = await prisma.cycleCalendarFeed.updateMany({ + where: { id: live.id, revokedAt: null }, data, + }); + if (written.count === 0) { + live = await findLiveFeed(user.id); + if (!live) return noLiveFeed(); + written = await prisma.cycleCalendarFeed.updateMany({ + where: { id: live.id, revokedAt: null }, + data, + }); + if (written.count === 0) return feedExists(); + } + const updated = await prisma.cycleCalendarFeed.findUniqueOrThrow({ + where: { id: live.id }, select: FEED_SUMMARY_SELECT, }); @@ -197,12 +220,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => { historyCycles: live.historyCycles, includeFertile: live.includeFertile, titleMode: live.titleMode, + forecastStyle: live.forecastStyle, }, after: { horizonCycles: updated.horizonCycles, historyCycles: updated.historyCycles, includeFertile: updated.includeFertile, titleMode: updated.titleMode, + forecastStyle: updated.forecastStyle, }, }), }, @@ -220,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 }); }); diff --git a/src/app/api/labs/overview/route.ts b/src/app/api/labs/overview/route.ts index 5b4d4ac2a6..a84fd5e39f 100644 --- a/src/app/api/labs/overview/route.ts +++ b/src/app/api/labs/overview/route.ts @@ -2,10 +2,12 @@ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { categoriseOverview } from "@/lib/labs/marker-category"; import { loadLabOverview } from "@/lib/labs/overview"; import { annotate } from "@/lib/logging/context"; +import { resolveModuleListLayout } from "@/lib/settings/module-list-layout"; import { labOverviewQuerySchema } from "@/lib/validations/labs"; /** @@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs"; * `categories` and `grouped` are the list's categories, resolved here once * (`src/lib/labs/marker-category.ts`): order, label, members, counts and a * combined status. Labels follow the viewer's locale, so on a managed record - * the caller's own language wins, not the record owner's. + * the caller's own language wins, not the record owner's. The order is the + * default one with the caller's `categoryOrder` (the labs list layout, + * `PUT /api/settings/module-list-layout`) applied, for the same reason: it is + * the viewer's presentation, so a person acting for another record sees the + * categories in their own order. A client renders the order as given. */ export const GET = apiHandler(async (request: NextRequest) => { const { user, actor } = await requireRecordAuth("read", "labs"); @@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const markers = await loadLabOverview(user.id); + const [markers, layoutRow] = await Promise.all([ + loadLabOverview(user.id), + prisma.user.findUnique({ + where: { id: actor.id }, + select: { moduleListLayoutJson: true }, + }), + ]); + const { categoryOrder } = resolveModuleListLayout( + layoutRow?.moduleListLayoutJson, + ).lists.labs; const locale = await resolveServerLocale({ request, userLocale: actor.locale ?? null, }); - const { categories, grouped } = categoriseOverview(markers, locale); + const { categories, grouped } = categoriseOverview( + markers, + locale, + categoryOrder, + ); annotate({ action: { name: "labs.overview" }, - meta: { markers: markers.length, categories: categories.length, grouped }, + meta: { + markers: markers.length, + categories: categories.length, + grouped, + categoryOrderLength: categoryOrder.length, + }, }); return apiSuccess({ markers, categories, grouped }); }); diff --git a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts index 28611e5fc2..348936784e 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -272,8 +272,13 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z")); }); - it("re-enabling a disabled short-cycle reminder schedules it from now", async () => { - const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false }; + it("re-enabling a disabled weekly calendar rule schedules it from now", async () => { + const weekly = { + ...BASE_ROW, + intervalDays: null, + rrule: "FREQ=WEEKLY", + enabled: false, + }; findFirstMock.mockResolvedValue(weekly); const res = await PATCH(makeRequest({ enabled: true }), params); @@ -303,6 +308,26 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n expect("nextDueAt" in persisted).toBe(false); }); + it("re-enabling a missed short rolling reminder keeps its open slot", async () => { + // Every two days, never measured: switching it off and on again is not + // measuring it either. + findFirstMock.mockResolvedValue({ + ...BASE_ROW, + intervalDays: 2, + enabled: false, + }); + + const res = await PATCH(makeRequest({ enabled: true }), params); + expect(res.status).toBe(200); + + const persisted = updateMock.mock.calls[0]?.[0]?.data as Record< + string, + unknown + >; + expect(persisted.enabled).toBe(true); + expect("nextDueAt" in persisted).toBe(false); + }); + it("reads a resent first due date on the same calendar day as unchanged", async () => { // A booster row stores the dose instant plus N days; the web form sends // the same day back as local midnight. Same day in the profile zone, so diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 336e45d688..da5a080d43 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -219,7 +219,7 @@ export const PATCH = apiHandler( const reEnabled = updateData.enabled === true && !existing.enabled; // Re-enabling a reminder that stays due after it is sent keeps the slot it // was on, overdue or not: switching it off and on again is not doing it. - // A short-cycle reminder is scheduled from now, as before. + // A calendar rule that recurs within the week is scheduled from now. const keepsOpenSlot = reEnabled && !cadenceChanged && diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts index cbf362c86e..8cd037c900 100644 --- a/src/app/api/settings/module-list-layout/route.ts +++ b/src/app/api/settings/module-list-layout/route.ts @@ -58,14 +58,15 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: Request) => { const { user } = await requireActorAuth(); - // 500 ids of up to 64 characters plus the envelope. + // 500 ids plus 200 category keys of up to 64 characters, plus the envelope. const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, { - maxBytes: 48 * 1024, + maxBytes: 64 * 1024, action: "settings.module-list-layout.validation-failed", }); if (!parsed.ok) return parsed.error; - const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data; + const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } = + parsed.data; // Preserve-when-absent: one stored read fills whichever field the body // left out, and every other list stays as stored. Only fields a person has // chosen are written, so a field never picked keeps following the default. @@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => { ...(sortDir ? { sortDir } : {}), ...(groupBy ? { groupBy } : {}), ...(groupsOpen ? { groupsOpen } : {}), + ...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}), }; const next = { ...stored, [list]: nextChoices }; await tx.user.update({ @@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => { groupBy: layout.lists[list].groupBy, groupsOpen: layout.lists[list].groupsOpen, orderLength: layout.lists[list].order.length, + categoryOrderLength: layout.lists[list].categoryOrder.length, }, }); return apiSuccess(layout); diff --git a/src/app/api/sync/changes/__tests__/start-now.test.ts b/src/app/api/sync/changes/__tests__/start-now.test.ts index 02f812fb10..1e7612ebbb 100644 --- a/src/app/api/sync/changes/__tests__/start-now.test.ts +++ b/src/app/api/sync/changes/__tests__/start-now.test.ts @@ -1,7 +1,7 @@ /** * `GET /api/sync/changes?start=now` (#1339) — a cursor at the present. * - * The answer is an empty page whose cursor sits a minute before `serverNow` + * The answer is an empty page whose cursor sits two minutes before `serverNow` * in every domain (a write stamped before it and committed after it is not * skipped), in the feed's own opaque format, and nothing is read: the point is * to skip the history walk, so a read here would be the bug. The database is @@ -55,7 +55,7 @@ beforeEach(() => { }); describe("GET /api/sync/changes?start=now", () => { - it("answers an empty page with a cursor a minute before serverNow in every domain", async () => { + it("answers an empty page with a cursor two minutes before serverNow in every domain", async () => { const before = Date.now(); const res = await GET(req("?start=now")); const after = Date.now(); @@ -82,7 +82,7 @@ describe("GET /api/sync/changes?start=now", () => { expect(cursor).not.toBeNull(); for (const domain of SYNC_DOMAINS) { expect(cursor![domain], domain).toEqual({ - updatedAtMs: nowMs - 60_000, + updatedAtMs: nowMs - 120_000, id: "0", }); } diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 6f126f02cc..e9c31b87c9 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -31,6 +31,12 @@ * - `hasMore` is true when ANY served domain still has rows past its * page; the next `cursor` advances every domain that returned rows. * When `hasMore` is false the client is caught up as of `serverNow`. + * A row domain that ran dry takes a watermark two minutes before + * `serverNow` (`drainedWatermark`; on the last page every one does): + * a row stamped in that stretch whose write commits after the pull + * still arrives on the next one, at the cost of reading the stretch + * twice, and a domain that stays quiet keeps a fresh watermark instead + * of ageing into `cursorExpired`. * - `cursorExpired: true` when ANY domain watermark predates the * tombstone-retention horizon — the client drops its cursor and does * a clean initial sync (a deletion older than retention may have @@ -43,7 +49,7 @@ * cycle tracking off gets both blocks empty (never a 403 — see the * gate note in the handler) and their cursor watermarks untouched. * - * `start=now` (#1339) answers an empty page whose cursor sits a minute + * `start=now` (#1339) answers an empty page whose cursor sits two minutes * before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same * opaque format, so a client that * only wants what changes from now on (the Apple Health write-back) pages diff --git a/src/app/insights/ketones/page.tsx b/src/app/insights/ketones/page.tsx new file mode 100644 index 0000000000..27899b8c3e --- /dev/null +++ b/src/app/insights/ketones/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Droplet } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { GkiCard } from "@/components/insights/ketones/gki-card"; +import { getUnitForType } from "@/lib/measurements/unit-map"; + +/** mmol/L, from the one canonical unit table; ketones have no unit choice. */ +const KETONE_UNIT = getUnitForType("BLOOD_KETONES"); + +/** + * `/insights/ketones` (#1367). + * + * Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric + * scaffold, with the glucose-ketone index beneath the chart. Readings are + * typed off a meter, alone or together with the glucose from the same + * fingerstick, so the empty state offers manual entry. No band and no + * assessment card: what a ketone reading should be follows the plan a + * clinician sets, and the page shows the readings as recorded. + */ +export default function InsightsKetonesPage() { + return ( + } + emptyStateCtaType="BLOOD_KETONES" + captureType="BLOOD_KETONES" + afterChart={} + /> + ); +} diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index ce8a9a162c..c1251cb8d9 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -261,6 +261,21 @@ export function MeasurementGroups({ max: show(type, stat!.max, digits), })} /> + {type === "BLOOD_KETONES" && + report.glucoseKetoneIndex && + scope.admits("BLOOD_GLUCOSE") ? ( + // #1367 — the index the server derived from the ketone + // and glucose readings, beside the ketones; no unit. + + ) : null} {unvalidated > 0 && (

[] as Array>); + +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) => { + 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( + + + + + , + ); + return lineProps.at(-1)?.stroke; +} + +beforeEach(() => { + lineProps.length = 0; +}); + +describe(" 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)"); + }); +}); diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index ddd7d74983..d3efa1b2e9 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -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 (

diff --git a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx index 6e30cf1bae..d113f71654 100644 --- a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx @@ -30,6 +30,7 @@ const FEED = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET" as const, + forecastStyle: "MARGIN" as const, }; describe("", () => { @@ -58,6 +59,32 @@ describe("", () => { expect(html).not.toContain("Fertile window"); }); + it("offers the three forecast styles and explains the chosen one", () => { + const html = render({ + feed: FEED, + projectionState: "OK", + fertileAvailable: false, + }); + expect(html).toContain("How forecasts look"); + for (const value of ["EXACT", "MARGIN", "BAND"]) { + expect(html).toContain(`value="${value}"`); + } + expect(html).toContain('aria-describedby="cycle-feed-forecast-hint"'); + expect(html).toMatch( + /data-slot="cycle-feed-forecast-hint"[^>]*>Expected days, with the days the period may start earlier or later as separate entries\. { const html = render({ feed: FEED, diff --git a/src/components/cycle/cycle-calendar-feed-card.tsx b/src/components/cycle/cycle-calendar-feed-card.tsx index ec6bcf74e9..4540459044 100644 --- a/src/components/cycle/cycle-calendar-feed-card.tsx +++ b/src/components/cycle/cycle-calendar-feed-card.tsx @@ -28,6 +28,7 @@ import { type CycleFeedMinted, type CycleFeedOptionsPatch, type CycleFeedStatus, + type FeedForecastStyle, type FeedTitleMode, } from "./use-cycle-feed"; @@ -44,6 +45,7 @@ import { const HORIZONS = [1, 3, 6] as const; const HISTORIES = [0, 3, 6, 12] as const; const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"]; +const FORECAST_STYLES: FeedForecastStyle[] = ["EXACT", "MARGIN", "BAND"]; /** A long URL with its middle elided, so both ends stay recognisable. */ function middleTruncate(url: string, keep = 28): string { @@ -352,6 +354,31 @@ function FeedOptions({ status }: { status: CycleFeedStatus }) {
+
+ + + save({ forecastStyle: e.target.value as FeedForecastStyle }) + } + > + {FORECAST_STYLES.map((s) => ( + + ))} + +

+ {t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)} +

+
{status.fertileAvailable ? (
diff --git a/src/components/cycle/use-cycle-feed.ts b/src/components/cycle/use-cycle-feed.ts index 58ab6ed646..997911a3c3 100644 --- a/src/components/cycle/use-cycle-feed.ts +++ b/src/components/cycle/use-cycle-feed.ts @@ -17,6 +17,7 @@ import { queryKeys } from "@/lib/query-keys"; import type { ProjectionState } from "./types"; export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE"; +export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND"; export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER"; export interface CycleFeedSummary { @@ -29,6 +30,7 @@ export interface CycleFeedSummary { historyCycles: number; includeFertile: boolean; titleMode: FeedTitleMode; + forecastStyle: FeedForecastStyle; } export interface CycleFeedStatus { @@ -46,7 +48,11 @@ export interface CycleFeedMinted { export type CycleFeedOptionsPatch = Partial< Pick< CycleFeedSummary, - "horizonCycles" | "historyCycles" | "includeFertile" | "titleMode" + | "horizonCycles" + | "historyCycles" + | "includeFertile" + | "titleMode" + | "forecastStyle" > >; diff --git a/src/components/day/__tests__/day-gki-tile.test.tsx b/src/components/day/__tests__/day-gki-tile.test.tsx new file mode 100644 index 0000000000..04dfa82f2b --- /dev/null +++ b/src/components/day/__tests__/day-gki-tile.test.tsx @@ -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( + + + , + ); +} + +function visiblePair(html: string): string { + const match = html.match( + /