Commit Graph
67 Commits
Author SHA1 Message Date
Marc-André Bombeck e59ef4533d fix(push): say why a test push failed, under its own title
The Web Push test answered a refused push with `ok: false` and the
sender's `reason`, but the Test button reads `meta.errorCode`, so it
only said "Test failed". The answer now carries `upstream_rejected`, or
`connection_failed` when no push service answered.

The test push used the administrator's test title, which put the brand
first in several languages; it has its own title now.
2026-11-12 14:07:19 +01:00
Marc-André Bombeck 7c3988865d fix(push): name the real button in the Home Screen hint
The hint for iPhone and iPad Safari ended with "turn on push here", but
the button reads "Enable push"; it now names the button in every
language. The hint is two sentences of steps, so it is shown as content
rather than muted, and the French one uses the typographic apostrophe
like its neighbours.
2026-11-12 14:05:37 +01:00
Marc-André Bombeck fd92e9fd23 fix(i18n): say "back online" once in the offline strip
The offline strip joins two sentences, and both ended on "once you're
back online"; in French it ran to four lines on a phone, and the
Russian one was ungrammatical. The first sentence now only says that
changes can't be saved.
2026-11-12 14:05:13 +01:00
Marc-André Bombeck 37734c9fd8 fix(auth): say why a registration or passkey sign-in was refused
Once the sign-in and registration pages read codes instead of the
server's prose, a taken username or address said only "Registration
failed", an expired invitation said "You don't have permission for
this", and a passkey this server does not know, a passkey sign-in that
took too long, an unreadable passkey answer, a single-sign-on-only
server and an already signed-in visitor all said "Login failed".

The registration route now names its refusals (auth.register.taken for
both 409s, auth.register.invite_invalid for a refused invitation), and
those codes, the three passkey refusals, oidc_only and
auth.already_authenticated get a sentence in every language.
2026-11-12 14:00:18 +01:00
Marc-André Bombeck 1dde3034c6 fix(auth): say a refused sign-in in the sign-in page's own words
The sign-in refusals carry only their status's generic code, so once the
sign-in page read codes, a wrong password, a wrong second-factor code
and a refused security key all said "Your session has ended", and a
lockout said "too many requests just now".

localizedSignInError reads a generic 401 as the step's own failure and a
generic 429 as "Too many attempts. Wait a few minutes and try again.";
a code the step names itself keeps its sentence. The password, passkey,
code and security-key steps use it. The rate-limit sentence moves from
auth.claim to auth.signInRateLimited, since the claim page and the
sign-in page now share it, and the Korean one takes the polite form the
rest of the catalogue uses.
2026-11-12 13:54:34 +01:00
Marc-André Bombeck a1591c4ea0 fix(i18n): keep one apiErrors.http block per locale
Two branches each added the block, and the merge kept both. A JSON parser keeps the last one, so the sign-in sentence for a rate limit was dead text.
2026-11-12 13:42:34 +01:00
Marc-André Bombeck 031379d966 Merge branch 'release/v1.47.3' into release/v1.47.4
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-12 13:27:52 +01:00
Marc-André Bombeck 8c9fbbdd2b fix(errors): name three refusals that fell back to a generic sentence
Since a codeless refusal reads by its status, three answers lost their
meaning. A failed ChatGPT sign-in poll said "Error saving" instead of
asking to start the sign-in again; it now falls back to the sign-in's
own failure sentence. A dose-history import still running when the
card stopped watching said "Nothing was written" although the job may
still write; that case now has its own sentence. Setting up an
authenticator while one is active answered "setup failed"; the route
now sends `auth.mfa.already_active`, which says to disable the active
one first, in every locale.
2026-11-12 12:58:52 +01:00
Marc-André Bombeck 671245c782 fix(documents): one cap line for both pickers, with a search-aware hint
The vault picker and the Coach's attachment picker each wrote their own
"showing the newest 50" line, which the UI standards leave to
`ListCapNotice`, and with a search running past fifty matches the line
still said "Search to find an older document". Both pickers now share
`DocumentPickerCapNotice` on `ListCapNotice`, which takes the sentence
to show, and a running search asks for a narrower one.
2026-11-12 12:51:32 +01:00
Marc-André Bombeck 92e1e24599 fix(i18n): correct the new record and workout refusals in five locales
The Russian, Polish and Korean refusals for a removed practice, visit
or check-up said "choose another or none" word for word; they now name
the picker's own empty choice or say to leave the field empty. Brazilian
Portuguese called a check-up a "check". The dose-name refusal names the
vaccination card the way the form does in English, Spanish, Polish and
Dutch, and the oversized workout batch no longer says "at once" twice.
2026-11-12 12:49:55 +01:00
Marc-André Bombeck 3ed266e281 fix(measurements): drop the stray space in a unit-less range message
A score, rating, count or ratio has no unit label, so the range
message for a manual pain score read "Enter a value between 0 and 10 ."
with a space before the stop, and the same in every locale. A second
sentence without the unit now answers those types.
2026-11-12 12:49:18 +01:00
Marc-André Bombeck 0c77eaa085 fix(errors): name the refusals behind the AI test and a passkey proof
Three refusals answered without an error code, so the web printed the
status's generic sentence. A connection test with nothing configured
read "Some of the entries were not accepted", and a mistyped password
while adding a passkey read "Your session has ended. Sign in again",
which sent people to sign out for nothing.

An empty chain now resolves to no provider, which the test route
answers with `ai.provider.none`. The passkey registration and the
step-up mint answer a failed or stale proof with `auth.reproof.failed`.
`ai.provider.none`, `ai.budget.exceeded` and `auth.reproof.too_weak`
get sentences in every locale.
2026-11-11 22:49:20 +01:00
Marc-André Bombeck 212ff9ee55 Merge branch 'fix/v1474-auth' into release/v1.47.4
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:32:28 +01:00
Marc-André Bombeck 85240ab858 fix(records): give the visit, vaccination and workout refusals a sentence
The visit, vaccination, allergy, address-book and workout forms read a
refusal's code through the catalogue, but nineteen of the codes their
routes send had no sentence: a stale doctor or check-up, a planned visit
that would close a check-up, a dose without a name, a booster that
cannot be planned, an invalid body, a workout batch that is too large.
Each of them read as the form's generic "could not be saved", which
hides what the person can change. They now have their own sentence in
all ten languages.
2026-11-11 21:32:23 +01:00
Marc-André Bombeck 188d21d6ac Merge branch 'fix/v1474-offline' into release/v1.47.4
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:32:11 +01:00
Marc-André Bombeck 0b7afd8460 fix(errors): show the catalogue sentence, not the server's text
About forty refusal messages still printed the error string the server
sent: English in every locale, and for a dropped connection the
browser's own TypeError text. Mood tags, targets and thresholds, family
history, medication side effects, phases, API tokens and dose imports,
the Coach model picker, the AI provider, fallback chain, timeout and
Codex cards, the notification channel cards, the avatar, passkeys,
security keys, the authenticator card, the re-proof dialog and the
admin pages now resolve the refusal's code through localizedApiError
and fall back to the surface's own sentence.

Two refusals carried something a person needs and had no code: a
private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last
passkey. Both now send a code with a sentence of their own. A throttled
re-proof says so in the reader's language. Raw fetch sites throw the
same ApiError as the verb helpers through the exported throwApiError,
which replaces the admin's getApiErrorMessage; the authenticator card's
copy of describeStepUp is gone, and describeStepUp no longer passes a
non-step-up refusal's prose through (its test pinned that).

The admin password reset keeps the server's verdict on a 422: the
server words the strength and breach verdict in the account's language.
2026-11-11 21:29:33 +01:00
Marc-André Bombeck ecb219f346 fix(auth): say a refused sign-in in the reader's language
The sign-in page (password and passkey), the second-factor step and
registration printed the server's English error text, so a German or
French page said "Invalid credentials" or "Invalid code". They now
resolve the error code through localizedApiError like the other forms
and fall back to the page's own sentence. Registration still shows a
422 as sent: that is the password feedback, which the server already
words in the reader's language.

A 429 carries the generic `http.rate_limited` code, which had no
sentence, so a person locked out by the limiter would have read only
"Login failed". The catalog now has one, reusing the wording of the
profile take-over's rate-limit message.
2026-11-11 21:21:20 +01:00
Marc-André Bombeck a4766824ef fix(auth): keep the page when a sign-out from an auth page is refused
The sign-out buttons on the registration and profile take-over pages
took every HTTP answer as success. A refused sign-out (the demo's 403,
a 429, a 5xx) wiped the client caches and reloaded the page while the
session stayed valid; on the take-over page a network failure was an
unhandled rejection with no message at all.

Both go through apiFetch now, which throws on a non-2xx answer, and say
that the person is still signed in: inline on the registration page, as
a toast on the take-over page. The message is the one the account
menu's sign-out uses.
2026-11-11 21:18:46 +01:00
Marc-André Bombeck 9227844f42 Merge branch 'fix/v1473-ai' into release/v1.47.3
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:07:27 +01:00
Marc-André Bombeck af623d12db fix(push): send the test push to every device, in the user's language
The Web Push test went to the newest subscription only. With a phone
and a laptop subscribed, Test pressed on the laptop buzzed the phone
and the laptop looked broken. The text was English in every locale,
and the push was dialled through the library's own transport, past the
pinned safeFetch that real sends use.

The route now calls sendViaWebPush, so the test reaches every
subscription, reaps endpoints the push service reports gone, and goes
out through the same dial as a reminder. Title and body come from the
account's locale. The response keeps ok and latencyMs and gains reason;
sent and perEndpoint are gone, nothing read them.
2026-11-11 20:52:36 +01:00
Marc-André Bombeck 926c427907 fix(coach): stop computing the plan review verdict nobody sees
The nightly plan review read a before/after window of the plan's metric
and stored an English verdict in `outcomeEncrypted` for every plan. No
screen and no route returned it; only the Coach snapshot read it, behind
the `COACH_EXPERIMENT_VERDICT` flag that is off by default. The worker now
only moves an active plan whose review fired to `reviewed`, which is what
takes it out of the standing group. The flag, its snapshot block, the
three eval cases for that block and the compose and env entries go with
it. The Coach prompt keeps its experiment rules and now ties the
read-back to the plan's progress line instead of the removed block.
Existing stored outcomes stay in backups untouched.

The plans page and the Coach reminders card printed the metric as a raw
key in capitals (WEIGHT, BLOOD_PRESSURE) in every language. Both now show
the measurement's name from the catalogue, through the plan metric
resolver moved to `plan-metric.ts`; a key HealthLog does not read shows
as written. The unused `coach.plans.proposalTitle` string goes.
2026-11-11 20:51:18 +01:00
Marc-André Bombeck 7f8ba0ae98 fix(push): tell iPhone Safari users to open the Home Screen app
iOS offers Web Push only to a web app opened from the Home Screen. In a
Safari tab the push card said the browser does not support push
notifications, and the step that fixes it was only in the docs. On an
iPhone or iPad outside the installed app the card now says to add
HealthLog to the Home Screen, open it from there and turn push on.

WebPushCard grows by two lines for the second sentence; its baseline
goes from 201 to 203, still below the 215 it had before the previous
change.
2026-11-11 20:46:58 +01:00
Marc-André Bombeck 6d8f533deb fix(auth): keep the page when sign-out is refused
The sign-out mutation took every HTTP answer as success. A refused
logout (403 from the demo guard, a 429, a 5xx) wiped the client caches
and showed the sign-in page while the session cookie stayed valid, so
the record pages opened again on the next visit. The call now goes
through apiFetch, which throws on a non-2xx answer, and the error toast
says the person is still signed in instead of naming a network error.
2026-11-11 20:42:25 +01:00
Marc-André Bombeck b793be3f84 fix(onboarding): shorten the AI pitch on the last setup screen
Every new account finished setup on a full AI panel: a gradient badge, a
sample briefing, three ways to connect a provider, a one-tap offer for the
operator's key and a disclaimer, the most prominent block on the screen.
It is now one muted line with a link to Settings, AI, shown under the same
rule as the Insights hint (could be set up here, nothing configured yet,
not opted out). That hint and the consent request on Insights and
Settings, AI stay the places that invite.

The sample briefing card, the `onboarding.ai.*` strings in ten locales and
the end-to-end spec for the removed offer go with it. The consent-grant
guard's floor drops to the two callers left.
2026-11-11 20:38:37 +01:00
Marc-André Bombeck 0df65ce396 fix(pwa): keep the open page when a link is tapped offline
Without a connection the strip said "Showing your last synced data",
but one tap on a tab or link traded the readable page for the worker's
bare offline page: signed-in pages are no-store, so the document
navigation Next falls back to has nothing to load.

While the browser reports offline, a tap on a link to another page of
the app now stays on the open page and repeats the hint in a toast. The
strip says what is true: this page stays readable, other pages open once
the connection is back. The README stops promising offline support.

A cold start or a typed URL without a connection still gets the offline
page; the e2e spec keeps that and adds the tap.
2026-11-11 20:33:57 +01:00
Marc-André Bombeck 17fd1a0906 chore(insights): remove the "Try a 7-day experiment" placeholder
Every correlation card carried a permanently disabled button with the
tooltip "Coming soon", which on a phone (no hover) was just a dead
control. The feature it stood for was never built; the button and its
two strings go in all ten locales. The Coach hand-off and the dismiss
action stay.
2026-11-11 20:30:24 +01:00
Marc-André Bombeck 6a02cda210 Merge branch 'fix/v1473-writes' into release/v1.47.3 2026-11-11 20:18:58 +01:00
Marc-André Bombeck 53f4ecc2c5 fix(documents): say when the document picker list is cut
The picker for linking documents to an illness and for choosing
documents for a share link reads the newest fifty and dropped the
route's nextCursor, so with a longer vault an older report seemed not
to exist unless one already knew to search for it. A cut list now ends
with a line that says how many are shown and that the search reaches
the rest. Paging was left out: the coach attachment picker shares the
picker's cache key with a single-page read.
2026-11-11 20:08:24 +01:00
Marc-André Bombeck d204b4344b fix(records): offer Undo after deleting a vaccination, visit or entry
Deleting a vaccination, a doctor visit or an address-book entry closed
the sheet with no way back, although all three are soft deletes with a
documented restore route. The vaccination hook had a restore mutation
nobody called; visits and the address book had none.

The two hooks gain a restore mutation, and each delete now confirms
with an Undo toast, the same pattern the illness episode uses. The Undo
runs after the sheet has closed, so it awaits the restore itself and
the hook's own onSuccess refreshes the lists, instead of relying on
callbacks a closed sheet never receives. Restoring an address-book
entry brings the entry back, not the links the delete cleared on the
visits that named it. The three new success toasts are pinned in the
success-affordance guard.
2026-11-11 20:06:02 +01:00
Marc-André Bombeck 5449933c4f chore: remove three components nothing renders
personal-record-badge.tsx, intake-history-editable.tsx and
intake-history-preview.tsx had no importer outside their own tests and
the preview, so they rendered nowhere while four guard tests kept them
listed as live surfaces. They go with their tests, their guard entries
and the strings only they used.

GET /api/personal-records stays: the native app reads it. The
resolved-field guard now records its benchmark band as a native-only
field instead of naming the deleted badge as its reader.
2026-11-11 20:03:00 +01:00
Marc-André Bombeck 121b23f4a2 fix(forms): show the form's own sentence when a save is refused
The custom metric, allergy, vaccination, booster, visit, address-book,
workout and quick-add dose forms printed the server's error string as
it came: English in every locale, and for a network failure the
browser's own TypeError text. They now resolve the error code through
localizedApiError like the other entry forms, so a known refusal reads
in the person's language and anything else falls back to the form's
"could not save" sentence. A custom metric whose name is taken keeps
its specific message, now translated.

The workout form's browser test expected the server prose and now
expects the form's sentence.
2026-11-11 19:57:06 +01:00
Marc-André Bombeck 7001308636 fix(measurements): mark an implausible edited value on its field
Typing 720 instead of 72.0 in the measurement edit sheet showed the
English banner "Validation failed" in every locale, with no field
marked. The route refuses a changed value outside the type's band with a
422 whose issue list names the field, and the client dropped that list.

The sheet now checks the same band before sending and says, under the
value input and in the unit it shows, which range is accepted. ApiError
carries the field paths of a refusal, so a value the server still
refuses lands on the field too; any other refusal goes through
localizedApiError instead of printing the server's prose.
2026-11-11 19:56:59 +01:00
Marc-André Bombeck 8057bb20bc fix(notifications): drop the four switches nothing sends
The notification matrix listed "Unusual measurement", "Low adherence",
"Withings sync failed" and "Personal record" with a switch per channel,
three of them on by default, yet nothing dispatches any of them: a reading
far outside its range or a week of missed doses produced no message.

eventTypesForRole now leaves them out for every role, next to the
admin-only filter, and the preferences read no longer serves rows stored
for them. Their labels in all locales and their tap targets are deleted.
The types stay in EVENT_TYPES, so the preference write and the stored rows
keep parsing; no migration.

The label and tap-target tests walked EVENT_TYPES and so pinned the four
in place; they now walk the event types that are sent.
2026-11-11 19:53:01 +01:00
Marc-André Bombeck 0639d28f6a fix(i18n): say "sign in again" or "too many requests" on a refused save
A save after the session ran out, or into a rate limit, showed the form's
generic "could not save". The server already names both refusals in
meta.errorCode (auth.missing for an ended web session, the status codes
http.rate_limited, http.forbidden and http.invalid otherwise), but the
catalogs had no sentence for them, so the lookup fell through to the
fallback. They have one now in all ten locales, and a refusal that
reaches the client without a code (a reverse proxy's 429) is read by its
status the way the server labels it.
2026-11-11 19:48:27 +01:00
Marc-André Bombeck 0b3c013af7 fix(i18n): name the period in the forecast style hints
Only the selected style's hint is shown, so "it may start" and "they
may fall" had no noun on screen to point back to. Name the period in
every locale that left it implied, and put the Korean hints in the same
register as the rest of the card.
2026-11-11 00:49:02 +01:00
Marc-André Bombeck 128cf55ab6 fix(day): fit the GKI tile's pair line in its column
The single-reading line spelled out both readings with their names and
units and was cut off after the glucose in every layout. Show the two
numbers with one unit, which fits the tile, and keep the worded pair for
assistive tech.
2026-11-11 00:46:30 +01:00
Marc-André Bombeck 2c63bf7a3a chore(labs): tidy the filter rail follow-ups
Drop a local-only ignore line, end the search placeholder with the
ellipsis character the other placeholders use, and put the
UrlFilterSyncOptions interface above the hook's doc comment so that
comment documents the hook.
2026-11-11 00:12:04 +01:00
Marc-André Bombeck 82ad1e2b45 fix(labs): call the date filter "Last measured"
The filter compares only the day of a marker's newest reading, so a
marker measured inside the range and again later drops out. "Date
range" promised more than that. The pill now reads "Last measured" in
every locale, and the from/to semantics are written down where the
filter is defined.
2026-11-11 00:10:44 +01:00
Marc-André Bombeck a486c3c8a3 Merge remote-tracking branch 'origin/release/v1.47.2' into takeover/1370
# Conflicts:
#	CHANGELOG.md
#	src/lib/module-list-prefs.ts
2026-11-11 00:04:51 +01:00
Konrad Garbal b4ac67ee7c feat(labs): add biomarker filtering and sorting 2026-11-10 20:46:57 +01:00
Marc-André Bombeck 69ba8bec91 fix(measurements): read the GKI's glucose from the ketone side
The correlated EXISTS over a time range had no equality to drive it from the ketones, so a person with years of sensor glucose walked every glucose row in the window. Each ketone reading now reads its glucose through a LATERAL index range. A full tie between two glucose readings resolves by id, the day tile names the unit, and the empty text says a ketone reading above zero.
2026-11-10 19:53:47 +01:00
Marc-André Bombeck 62f4578874 Merge branch 'feat/v1472-ketones' into feat/cycle-feed-forecast-style
# Conflicts:
#	CHANGELOG.md
2026-11-10 19:31:31 +01:00
Marc-André Bombeck f75337e6f2 feat(measurements): record blood ketones and show the glucose-ketone index (#1367)
Ketones had to live in a custom metric, with the glucose-ketone index typed
in by hand beside it, where it drifted from the readings it came from.

- Type: BLOOD_KETONES (beta-hydroxybutyrate, mmol/L, migration 0496),
  plausible from 0 to 10: the blood ketone meters read 0.0 to 8.0 and print
  HI above it, and a venous value off a lab sheet fits under 10. Zero is a
  real reading. No band, no direction, no personal record, off the Coach
  snapshot and the correlations; readable over MCP and owned by the glucose
  module like glucose.
- Entry: choosing blood ketones in the measurement form also takes the
  glucose from the same fingerstick, in the account's glucose unit. Both
  rows go through the array arm of POST /api/measurements with one
  timestamp, so they land in one transaction or not at all.
- Index: glucose (mg/dL / 18.0182, the tree's one factor) over ketones,
  computed at read time and never stored. Each ketone reading above zero
  pairs with the glucose reading at the same instant, else the nearest one
  within 15 minutes either side; a tie goes to the same source, then to the
  earlier reading. Nothing pairs across a wider gap.
- Surfaces: GET /api/measurements/series takes kind=ketones and
  kind=glucoseKetoneIndex (each point names the paired readings), and the
  day carries glucoseKetoneIndex when both reading types are visible. The
  new Ketones page charts the readings with the index beneath; the day view
  shows the index beside the value tiles; the doctor report, its PDF and the
  clinician share print it in the glucose group when both leaves are chosen,
  since beside the ketones it gives the glucose back.
- FHIR: ketone readings export under LOINC 104816-4 (Beta hydroxybutyrate
  [Moles/volume] in Blood). The index is left out: it has no LOINC term,
  and both readings it is built from are in the bundle.
- The measurement form's action row moved into its own component to keep
  the form inside its size budget.

OpenAPI regenerated; all ten locales; the import unit table lists the type.
2026-11-10 19:28:57 +01:00
Marc-André Bombeck 928ade5109 feat(labs): let a person order the lab categories (#1349)
The labs list layout gains categoryOrder, a bounded allow-list of
category keys (200 entries, 64 characters each, deduplicated, malformed
values dropped on read). GET /api/labs/overview reads the caller's own
layout and answers categories already in that order, so every client
renders them as given. Listed categories come first; keys the data does
not have are ignored; a category the order does not name follows in
the default order (catalog panels, then the person's own by label, then
other). Other left at the end of the order stays last, behind any
category added later; moved anywhere else, it stays there.

Settings > Arrangement > Labs shows a category order card while
categories are on. It lists the overview's categories under their
localised labels, sends the whole order after every move, moves the
cached overview at once and refetches it. The card says how the default
order works. The OpenAPI document carries the new field.
2026-11-10 18:57:48 +01:00
Marc-André Bombeck 2dfcadbea1 feat(mental-health): crisis contacts for South Korea
Korean, and any locale naming the region kr, resolves to 119 and the national suicide prevention line 109 instead of the international set.
2026-11-10 18:34:00 +01:00
Marc-André Bombeck 546b9b2569 feat(cycle): choose how the calendar feed draws a forecast (#1350)
A forecast period was one all-day event over every day it might fall on,
so a period of a few days showed up as a week-long estimate next to the
logged ones. Each feed now carries a forecast style:

- EXACT: the calculated period only, from the most likely start for the
  predicted number of bleeding days.
- MARGIN: that event, plus separate "possible" events for the days it may
  start earlier and the days it may still run on, each only where the
  band reaches that side. Discreet titles use an open dot for them.
- BAND: the single range, unchanged.

Migration 0495 adds the column with BAND as its default, so every live
feed keeps the events it serves. A feed turned on from now on starts on
MARGIN, set by the mint route. PATCH changes it, rotate copies it, the
status and the audit diff carry it, and the settings card offers it with
a one-line explanation of the chosen style. The horizon and its stop rule
are the same in every style, and the period event keeps its UID across
styles so a calendar updates it in place.
2026-11-09 10:59:18 +01:00
Marc-André Bombeck 4921188f22 fix(i18n): carry the count in One strings where one is more than 1
The plural tier comes from CLDR, and in Russian the one category also
holds 21, 31, 101, while French and Brazilian Portuguese file 0 there.
Thirty One strings in those bundles spelled out the 1, so 21 days read
as one day. They now take {count}, as do two Russian One strings that
spelled the number as a word, and every direct call of such a key passes
the count. A guard finds the locales whose one is wider than 1 from
Intl.PluralRules and holds both ends.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 74e691cecd feat(timeline): draw person-defined symptoms as value lines
A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.

`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.

The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
2026-11-07 00:38:53 +01:00
Marc-André Bombeck d9a31524a2 feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.

The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.

/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.

The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck 65f1a3db91 feat(labs): keep a marker's panel when it is merged into one without
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.

The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.

Refs #1349
2026-11-06 17:53:56 +01:00