1184 Commits
Author SHA1 Message Date
Marc-André Bombeck 668f568289 feat(dashboard): let the top card be switched off
The layout settings gain a switch for the dashboard's top card (the Today
card above the tiles). Stored server-side on the layout blob as
`todayCardVisible`: missing means shown, only an explicit false hides it,
the default is omitted from storage, a save that omits the field keeps
the stored choice, and a layout reset shows the card again. Hidden, the
card leaves no skeleton or error card behind. The flag rides the
account-settings backup with the rest of the layout.

The field is deliberately not `heroVisible`, the retired opt-in hero's
flag that older stored layouts may still carry.

An end-to-end journey on its own account switches the card off and on
and walks the score histories' range tabs.
2026-10-09 16:52:39 +02:00
Marc-André Bombeck 62140a4aee feat(insights): follow every score over time with one range selector
The health score had no history anywhere, readiness and the sleep score
showed a fixed sparkline, and only the stored nightly scores offered the
range tabs. Every score page now charts its course with the same
7 / 30 / 90 / All tabs, remembered per page, and each point opens its day.

- GET /api/insights/score-history?score=&days= serves the daily course of
  the health score (its stored day rows), readiness (the nightly blend,
  filed on its wake day) and the sleep score (each night against the
  trailing window ending on it), so a point equals what the day view shows
  for that day. MANAGE record read, module-gated for readiness and sleep,
  days 1 to 3650, additive.
- The health score's line breaks where its recipe changed (configuration
  or algorithm version); the usual range is formed from the newest side
  of the last seam.
- The range tabs move into one shared component used by every chart;
  recovery, stress and strain keep their measurement chart, now with the
  range remembered and no overlay dropdown.
- New page /insights/health-score, linked from the score panel and from
  the day view's score tile.
2026-10-09 16:45:39 +02:00
Marc-André Bombeck 1d080ee166 Merge the day picker, Coach new-chat button, day panel on /coach and text cleanup into release/v1.42.0 2026-10-09 15:11:24 +02:00
Marc-André Bombeck 4ca2334805 Merge the timeline rework into release/v1.42.0 2026-10-09 15:02:46 +02:00
Marc-André Bombeck 1440ad4301 feat(timeline): one row per medication, no cap on value lines, range popover
A medication arrived as up to four kinds of item (its own span, its
courses, its dose changes, its pauses) and was drawn item by item, so a
medication with a course read as two bars on two rows. Every item of one
medication now carries the medication's id as `group`, and the lane draws
one row per group: the courses are the stretches it was taken, each dose
change cuts them into pieces labelled with their dose, and a pause is a
gap in the same bar. The selection bar and the phone chronicle list one
entry per medication with the dose in the text.

The value series cap of six is gone. The response carries
`availableSeries`, every series the record can show, and the picker is
one compact "Values (n)" button whose menu lists them. Past the fifth
line the five data colours repeat; every line is named at its left.

Choosing a range opens a popover anchored to the "Range" segment (a sheet
on a phone) with "Apply", so the chart no longer moves; the URL keeps the
range as before. The selection bar sits on the card under a hairline
instead of a grey box, the legend explains the dashed stroke and the
hollow point, and no timeline text joins its parts with a middle dot or
calls an open span "ongoing".
2026-10-09 15:02:14 +02:00
Marc-André Bombeck 6033935449 Merge v142/day-scores into release/v1.42.0 2026-10-09 14:22:21 +02:00
Marc-André Bombeck 745bcb64b7 Merge v142/webhook-discord into release/v1.42.0 2026-10-09 14:22:13 +02:00
Marc-André Bombeck 6e2a8fcd9f feat(day): show the day's scores and the cycle day with its phase
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.

Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.

Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.

A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
2026-10-09 14:03:31 +02:00
Marc-André Bombeck 08f532b428 feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.

The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.

The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
2026-10-09 13:44:58 +02:00
Marc-André Bombeck d029e4e1dc fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority
only. Discord refuses a body without content (400, code 50006 "Cannot
send an empty message") and a Slack incoming webhook one without text,
so the test button and every reminder failed against the targets the
settings card names.

The generic body now appends content (cut to Discord's 2000 characters)
and text, both the masked title and message on two lines, plus
allowed_mentions with an empty parse list so Discord resolves no
mentions. The four original fields keep their values and order for
Home Assistant and n8n rules. The Gotify body is unchanged. The card
names what works and shows a hint for the generic format.
2026-10-09 13:43:28 +02:00
Marc-André Bombeck 2638174b66 fix(timeline): word every code an entry carries, and read a pause as one
The timeline and the day view showed what the server sends as it was sent:
a life event's category as FAMILY, a visit's kind as PREVENTIVE, a
document's kind as DISCHARGE_LETTER, a trip and a cycle under their
sentinel labels, a lab day as a bare "5", a mood as SUPER_GUT, a
screener as PHQ9 with its band code. A pause read as "Ramipril" at its
start and "Ramipril ended" at its end.

Every surface that shows a timeline item (lane labels, hover titles,
the screen-reader table, the selection bar, the phone chronicle) now
reads it through one wording, item-words.ts: a code goes through the
bundle's existing labels, a lab day reads "5 lab values", and a code
the bundle does not know is left out rather than shown. The day view's
title and second line do the same per event kind (mood, screener and
band, sport, severity, visit kind or planned, dose number, intensity,
functional impact, document kind), and a medication that starts, ends,
pauses or resumes says so, in the day view and in the visit
preparation alike.

A pause reads "Ramipril paused" at its start and "Ramipril resumed ·
after 11 days" at its end, on the lanes, in the selection bar and in
the chronicle.

A guard renders a record with every item kind, every category and every
kind code on each surface, in two languages, and fails on any upper-case
code or sentinel in the visible text. The wire schema now says which
fields carry codes.
2026-10-09 12:53:56 +02:00
Marc-André Bombeck 470ddeedc4 fix(version): move the finer geo state to the admin status
geoLookup and geoProviderChosen on the public /api/version added one
more way to tell instances apart. They now live on /api/admin/status
(requireAdmin, cookie only) as geo { lookup, providerChosen,
providerHost }, which both admin status views read. offlineGeoEnabled
and geoProviderHost stay public as before.
2026-10-09 10:44:18 +02:00
Marc-André Bombeck c40b21de3d Merge the Coach header, timeline colours, selection bar and range into release/v1.42.0 2026-10-09 10:18:23 +02:00
Marc-André Bombeck 57c44006ee Merge the document layout default and the air quality history into release/v1.42.0 2026-10-09 10:02:56 +02:00
Marc-André Bombeck 61155d1856 feat(timeline): add a chosen range as a fourth zoom
Beside All, 12 months and 3 months the timeline takes a range of the
person's own choosing, from and to, through the app's date field. Each
change is clamped softly: never past today, never before the record's
first entry, never inverted, never longer than fifteen years. On a phone,
where the chronicle has no zoom control, a range button opens the same
fields with a way back to the whole record. A range with nothing in it
keeps its fields instead of the empty-record notice.

The zoom and the range live in the URL (zoom, from, to), so Back and a
bookmark return to them; a parameter that does not read falls back to
All. The API accepts zoom=range with both ends, at most fifteen years,
and picks the bucket by its length: quarters from two years, months from
120 days, weeks from 42 days and single days below, a new day bucket
whose dashed bridge spans at most three missing days. A range borrows
the grid and the keyboard steps of the fixed zoom nearest its length.
The selection bar heads a day bucket with that day.
2026-10-09 10:02:37 +02:00
Marc-André Bombeck d81a6ddf25 feat(environment): fill air quality for every past day with entries
A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.

Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.

The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
2026-10-09 09:43:01 +02:00
Marc-André Bombeck 898729d1a8 feat(insights): explain the shorter correlation list once
Since correlations are tested on seasonally adjusted residuals with an
effective sample size, an account with history sees far fewer
relationships, which reads as a defect without a word about it.

The correlations response now carries findingsBeforeSeasonalAdjustment:
true when the record kept a discovery pattern first stored before the
change, one indexed lookup on the pattern store. An account created since
can never qualify. The mood "What stands out" card shows a short note
for those records, closable, remembered per viewer like the AI setup
hint. Copy in all seven locales; OpenAPI regenerated.
2026-10-09 09:29:38 +02:00
Marc-André Bombeck f6f0969da9 Merge the timeline buckets, gaps and series limit into release/v1.42.0 2026-10-09 09:04:11 +02:00
Marc-André Bombeck af982e3812 feat(timeline): average value series per quarter, month or week with reading counts
The server picks the bucket per zoom and sends it as `bucket`: quarters
for an `all` window over two years, months for `year` and a shorter
`all`, weeks for `quarter`. Each point carries the readings behind its
mean as `count`; a bucket without a reading stays absent. Means remain
the mean of day values, so pulse-like types keep the hourly-mean day.
`values` accepts up to six series.
2026-10-09 09:03:42 +02:00
Marc-André Bombeck fb7f1eea61 Merge the day running-items and docked-panel changes into release/v1.42.0 2026-10-09 08:54:46 +02:00
Marc-André Bombeck 7340cd3ab7 feat(documents): run the vault's months side by side by default
An account that never picked an arrangement now sees the months flowing
across the width. The layout PUT stores only the fields the person chose,
so an untouched field keeps following the default while a saved choice
stays.
2026-10-09 08:50:20 +02:00
Marc-André Bombeck 0a6811c03f fix(admin): read a geolocation provider the operator named as a decision, not a warning 2026-10-09 08:38:58 +02:00
Marc-André Bombeck 09f94d3eac fix(day): run only records with a start of their own through a day
The "Running that day" list carried the profile facts (smoking status,
alcohol pattern, shift schedule) as running items, titled by their raw
codes and counted "day n" from the date the fact was filed. A person who
quit smoking years ago and entered it last week read "day 13". Those facts
describe the person, hold no start the person gave, and are not part of a
day; the lifestyle section and its running kind are gone from the day, its
index and the model-facing get_day.

The same false count reached a medication filed without a start date: its
creation date stood in as the start. `since` is now null there, with no
day count, and the entry's date only bounds which days show it. A life
event's category is worded in the reader's language instead of its code.
2026-10-09 08:38:01 +02:00
Marc-André Bombeck c52ad73ff7 chore(release): v1.42.0 2026-10-08 23:04:20 +02:00
Marc-André Bombeck 6208a5357a docs(api): describe the folded_window duplicate and the fold-free sync feed 2026-10-08 22:58:42 +02:00
Marc-André Bombeck 055c48a3fe fix(handover): show the link holder display names only
The claim preview is anonymous: whoever holds the link has proved
nothing yet. It listed each guardian's grant id and fell back to their
login name. It now carries the display name alone, null when there is
none, and the page says "a guardian" for that.

The notifications to the guardians fell back to the new owner's login
name when the record had no display name. They now use sentences about
the person the guardian looked after instead.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck f950dffad4 fix(sharing): keep life events to their owner
Life events rode the profile sharing domain, so a profile share, a
legacy whole-record share and a MANAGE grant all read them, and MANAGE
could write them. No share level was ever worded for them, so in this
release they are the owner's only.

The life-event routes take a bare requireAuth, which refuses under an
acting-account switch. The day section and the timeline's life lane map
to no domain, so a delegate sees the section named not_shared and never
gets the lane, its readiness row or the day index marks. The profile
consent copy no longer names life events, the sharing guard and the
mutation inventory drop the four entries, and the OpenAPI descriptions
say owner-only.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck e2ed62c74f docs(api): publish the environment backfill limits 2026-10-08 22:37:56 +02:00
Marc-André Bombeck 51374274aa List only the sources an account uses on the source-priority page
The read and save of /api/auth/me/source-priority now carry inUse: the
ranked sources the account has connected or holds at least one
measurement from, one index probe per source. The settings page lists
only those, hides a metric nothing delivers, and opens one metric group
at a time, the first by default, with the winning source named on each
collapsed line. The ladders stay whole: moving a source swaps it with
its nearest listed neighbour, so a hidden source keeps its slot.
2026-10-08 20:40:39 +02:00
Marc-André Bombeck ac482c508d Serve the device's day strain on the strain page when no proxy exists
The recovery page charts the band's own DAY_STRAIN while the strain
score page read only the computed proxy, so one account showed weeks of
strain on one page and 'not enough data' on the other. Without a proxy
row in the window the derived strain read now falls back to DAY_STRAIN:
the score is the reading as a share of the device scale, an additive
'device' block carries the native value, and the course below the card
charts that same series.
2026-10-08 18:17:20 +02:00
Marc-André Bombeck 4273af231e feat(documents): choose how the vault is laid out, and confirm every delete
The wrench beside Upload opens Settings, Appearance, Documents, the way
the illness journal offers its display options: preview cards or a
compact list, and months stacked as blocks or flowing across the full
width with the month name inline above the document that opens it. The
choice is stored per account in `users.documents_layout_json` behind
GET/PUT /api/documents/inbound/layout (preserve-when-absent, module
gated, the read delegable like the rest of the vault), carried in the
account-settings backup and cleared by the data wipe.

Deleting from the vault now always asks first. The bulk bar's Delete and
a focused card's Delete key both go through one confirmation on the page,
and the detail sheet's Delete opens its own; before, all three deleted on
the spot with only an undo toast behind them.

The selection bar is two tight lines: the count with an X at its right
edge, then one action row with Delete alone at the right in the
destructive style. Below 1024 px the verbs are icon-only at the 44 px tap
floor, so the row stays one line at 360 px. The bar carries the
selection-bar slot the Coach launcher already steps aside for.
2026-10-08 16:45:06 +02:00
Marc-André Bombeck 0924285cd6 feat: serve the day, its index, the timeline and life events
Replace the day, timeline and life-event stubs with their implementations
(#613) and drop the not-implemented helper, its error code and its OpenAPI
response with the last stub.

loadDay reads one local day for every reader: the readings live inside the
record's own day window (never the UTC rollups), through the canonical-source
engine the mood sheet already uses (resolveDayReadings in linked-context.ts),
with the person's usual range over the 30 days before; the record sections
from their own tables, each in the storage form it speaks (an instant in the
local window, a date string with its own zone, a calendar date). A switched-off
module's section is not read; a section the grant does not cover is named
not_shared and not read.

The day, the day index and the new visit preparation (GET /api/day/notable)
are admitted on the readings and narrowed per section through
actingDomainVisibility, so a scoped grant sees exactly its sections. The
timeline and its readiness stay whole-record reads and narrow their lanes the
same way.

Notable days are deterministic keys: a three-month high or low with history
behind it, a first reading, and in the visit preparation a regular reading
that stopped. The timeline lays out spans and points from the owning tables,
standing items without a date, and series by zoom: months from the rollup
tier inside its five-year window and from one cached live query before it.
The readiness inventory rates each lane with one link per gap.

Life events are created, edited and soft-deleted as MANAGE verbs with audit
rows, encrypted at rest, and join the sharing guard and the handler matrix.
get_day reads a day for the Coach and the MCP endpoint without life events,
notes or document names; its step carries the date it read.
2026-10-08 14:31:13 +02:00
Marc-André Bombeck b8f6372963 Merge the day and timeline contract into release/v1.42.0
# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-08 13:13:49 +02:00
Marc-André Bombeck 9d4a50282e Merge tickets part 2 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 13:07:02 +02:00
Marc-André Bombeck c79fc6cc7b feat: lay down the day view and timeline contract
Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).

Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.

src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.

The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.

The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
2026-10-08 13:03:37 +02:00
Marc-André Bombeck b299904552 Let a record define its own vaccines
A dose can now name a vaccine the catalogue does not list. The person
defines it once (a name, the diseases it protects against from the
catalogue's antigen list, the series length and the booster interval) and
logs doses against it; such a dose counts into each listed antigen's
series, settles a booster reminder keyed to one of them and offers a
booster, the way a catalogue combination does.

One resolver (resolve-vaccine-entry.ts) now answers for every consumer
that asked the catalogue directly: the series, the satisfy matcher, the
booster mint, the dose DTO, the doctor report, document links, the
upload suggestion, MCP search and the client's info sentences and
booster offer. The catalogue wins when a dose carries both.

The custom routes resolve the record through requireRecordAuth (list at
READ, create at WRITE with the actor-keyed write bucket, edit and remove
at MANAGE with the C4 overwrite audit) and join the sharing guard, the
MANAGE matrix and the activity feed. Removing a definition tombstones it
and lets go of its doses, each keeping a name. Definitions travel in the
backup and restore before the doses that name them; the dose form picks
them, adds one without leaving the form, and the page lists them for
editing. With the last contract stub implemented, the 501 helper and the
not_implemented code leave the tree.
2026-10-08 13:00:37 +02:00
Marc-André Bombeck 861ba21086 Merge tickets part 1 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:35:07 +02:00
Marc-André Bombeck 7eb5659ab9 Merge iOS server asks and client work into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/auth.ts
2026-10-08 11:27:42 +02:00
Marc-André Bombeck 12e422c25e Lighten the client shell and finish four client surfaces
- Load each settings section on its own: the section route imported all
  twenty-one statically. A preload starts the chunk beside the record
  gate's read, and the loading state paints the settings card shell.
  /settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
  run on each boot is plain TypeScript now, held to the published schema
  by an equivalence test over good and broken blocks. The canonical unit
  table moves to its own module, so the display transform no longer
  pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
  304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
  instead of the smooth scroll that lost the end, then follow late
  layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
  across on a phone and three or four on a desktop, with the month
  headings kept.
- Add the admin card for the measurement table maintenance: a status
  read on the maintenance route, one calm confirmation, and the run's
  state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
  watched at 403, aggregate cap 4335 with the reason written down.
2026-10-08 11:25:11 +02:00
Marc-André Bombeck 920bd19617 Merge managed profile handover into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:21:25 +02:00
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00
Marc-André Bombeck 7bcb6f71d9 Merge environment data into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 10:04:45 +02:00
Marc-André Bombeck 3c98d95a40 feat(environment): air quality, pollen and UV per day, locations sealed at rest
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather:
17 hourly variables folded into local days by fixed rules (means and maxima
only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only
with the midday hours), a value the feed did not cover stored as null, never
as zero. Requests are cut into 90-day chunks, newest first, and chunks before
mid 2022 ask for the eight pollutant and index variables only. An air-quality
failure never costs the weather row; the day stays unfetched and the nightly
gap fill (up to 90 days per account, newest first) asks for it again.

Every Open-Meteo request asks an instance-wide budget kept in Postgres first
(500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing
rule). A refused request is not sent and the run ends as done with
budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED
are honoured; the account switch lives on PATCH /api/environment/preferences.

The home, each dated location period and each stored day keep their coarse
location sealed as one value under healthlog/environment-location/v1. The
writers write the sealed value only, the readers open it and fall back to the
readable columns for rows the free-text encryption backfill has not reached,
and the backfill now seals all three and clears the readable columns. The
backup carries the location readable in a portable file and sealed in a
disaster-recovery file; a restore seals either under the receiving key.

Three correlation channels join the environment family (PM2.5, the ozone
8-hour high, the day's highest pollen count), read through the averaged
same-day window and left empty while air quality is off. The Coach and MCP
gain get_environment: module-gated, clamped to the lookback limit, no
coordinate or place name anywhere, with provenance and the attribution
lines. The settings section gets the switch, the coverage and the newest
day's values; the dashboard shows a quiet chip on a day with high pollen,
a warm night or very poor air. Copernicus and Open-Meteo are credited next
to the values.
2026-10-08 10:02:39 +02:00
Marc-André Bombeck 996d2519d1 feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.

The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.

Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
2026-10-08 09:54:42 +02:00
Marc-André Bombeck 2731c46399 feat(workouts): edit a hand-entered workout from its detail page
A workout logged by hand could only be deleted and logged again. The
detail page now offers an edit control beside delete for a MANUAL workout
carrying the form's `manual:` id, in one's own record. It opens the log
sheet on the stored values and saves by re-posting that id through
`POST /api/workouts/batch`, which already applies such a re-post as an
overwrite of that one row. No new route.

The overwrite nulls every overwritable column a re-post leaves out, and
the form has no fields for heart rate, steps, elevation or pauses. The
edit therefore sends those back as stored. A field left as shown sends the
stored value rather than one re-parsed from its display, so saving an
untouched edit keeps the start's seconds and a distance shown in miles at
its exact metres, and answers `duplicate`.

The detail read's `avgHr` / `maxHr` can come from a same-session twin;
the read now also serves the row's own `storedAvgHr` / `storedMaxHr`,
which is what the edit sends back.
2026-10-08 09:28:56 +02:00
Marc-André Bombeck ba99551faa feat(apple-health): show when each type last arrived and how
The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.

The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.

`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.

The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
2026-10-08 09:22:49 +02:00
Marc-André Bombeck aca497d9ac fix(insights): correlate residuals, not the calendar
Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.

Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.

The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.

scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
2026-10-08 08:32:15 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck b184a4bbac chore(release): v1.41.2 2026-10-07 22:48:49 +02:00