Commit Graph
2687 Commits
Author SHA1 Message Date
Marc-André Bombeck a2497cf21d Make the checkup due line say the same thing on both screens
The dashboard summary card and the checkups page each worked out "today",
"tomorrow", "in N days" and "overdue by N days" from the same due date, and
each did it with its own copy of the arithmetic. The page compares calendar
days. The card subtracted one instant from the other and divided, so a checkup
due Sunday at 01:00 read "tomorrow" when you looked on Friday evening, one due
Saturday at 23:59 read "in 2 days" when it was in fact tomorrow, and one due at
09:00 quietly stopped saying "today" as the afternoon wore on.

Both now read from one place, so they cannot drift apart again.
2026-07-31 07:44:20 +02:00
Marc-André Bombeck 1a0d2795c3 Merge work/backup-severity 2026-07-31 07:44:09 +02:00
Marc-André Bombeck 92da02f824 fix(backup): carry how hard a symptom hit through export and restore
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.

The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.

A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
2026-07-31 07:37:48 +02:00
Marc-André Bombeck 8488092d48 test(medications): prove a dose taken before midnight really stops reminding
The overnight reminder tests handed the check every intake row the
fixture held, whatever date range it had actually asked for. That left
the suppression assertion unable to fail. Narrow the read back to the
day the check happens to run in and the 23:45 dose logged at 23:50 drops
straight out of it, yet the test stayed green. The fake now honours the
scheduledFor range it is given, so the assertion answers the question a
person really asks: does it stop pestering me about a dose I already
took.

Two cases were missing at the other end of the same boundary. A dose
genuinely missed at 23:45 still has to escalate in the small hours, and
the missed-dose row it leaves behind has to name the evening it belongs
to rather than the morning the check runs in. Both are pinned now, next
to the case where the dose was taken and no missed row may be written at
all.
2026-07-31 07:36:17 +02:00
Marc-André Bombeck 9b69be8ab5 fix(openapi): declare shrunkR and tier on DiscoveredCorrelation
The correlation-discovery engine always sets shrunkR (the
sample-size-shrunk effect) and tier (the phrasing-confidence class)
on every surfaced pair, but the closed DiscoveredCorrelation schema
never declared either — same defect class as the dashboard snapshot:
additionalProperties: false rejecting a field the runtime actually
sends. Both are required, matching src/lib/insights/correlation-discovery.ts.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck d04eab71a3 fix(openapi): declare the dashboard snapshot fields the route actually emits
GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.

Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck 12d3239de0 feat(nutrients): tell the account holder when a sync arrived but nothing landed
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.

The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.

This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
2026-07-30 23:30:28 +02:00
Marc-André Bombeck 3ec3a8c6aa fix(profile): stop echoing the raw save error verbatim on Settings
The Settings > Account save handler read json.error straight off the
wire and rendered it as-is - the exact path a raw validator message
would have reached a person's screen through, and the only place the
report's symptom actually reproduces (baseline-form already goes
through the errorCode-aware localizedApiError helper).

It now resolves meta.errorCode into a localized sentence, the same
convention used everywhere else in the app, and never touches the
top-level string. A partial save (some fields landed, one didn't)
gets its own message naming the rejected field via the same label
already shown next to that input on this form, and reseeds the
form from what the server actually persisted so a field that was
silently skipped can't read as saved.

describeRejectedProfileField lives in account-section-utils so both
the warning and the failure path share one field-name-to-label
mapping instead of duplicating it.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck 356888134d fix(profile): localize the email-conflict message too
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.

Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck e49bd866dd fix(profile): save every valid field even when a sibling is invalid
A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.

The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck a61cdae437 Offer the previous session of the same sport from the workout page
"How does this compare to last time?" is the most likely next question
after reading a workout, and answering it meant going back to the list and
scanning for the one before.

Resolve the previous session server-side, through the same canonical picker
the list uses, so the link can never land on a duplicate row recorded by a
second watch, and render it beside the own-history average line. Null when
nothing older exists, and then no link is shown.

The stats card's hand-rolled heading moves to the icon-less TileHeader
while the file is open.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 76b407b6de Render the day around a workout instead of linking away from it
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.

Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.

The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.

The detail page also drops the description it borrowed from the list.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck 0094445f07 Date a workout by the user's timezone on the detail endpoint
The detail payload carried the session's start timestamp but nothing that
said which local day it belongs to. Anything that wants the day around a
workout had to slice the ISO string, which is the browser's day, not the
person's: a 20:00 UTC session reads as the 15th in London and the 16th in
Auckland.

Resolve the day server-side from the user's timezone and ship it as
dayKey. Additive, so a native client that does not read it is untouched.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck 37496f0083 docs(openapi): add the account-wide import job-status poll, fix a second dangling schema
GET /api/medications/intake/dose-history-import/{jobId}/status reads
through the same readMedicationIntakeImportJob projection as the
per-medication route, so both 200 responses now point at one shared
MedicationIntakeImportJobStatusEnvelope object instead of two
independently generated copies.

Audited every other route description for a schema named by id that
never resolves to a components.schemas entry. Found one more:
CoachPrefs is named in the coach-prefs PUT description, but every
actual consumer is a .extend() of it, so the base never carried
forward to a $ref. Force-registered it the same way as the
dose-history fatal-reason enum. The remaining unresolved meta ids are
request/query schemas zod-openapi inlines rather than hoists — none of
them are named by id in another schema's prose, so nothing points a
reader at a component that silently isn't there.
2026-07-30 20:12:06 +02:00
Marc-André Bombeck 8b40066f42 docs(openapi): add dose-history import and its job-status poll
Documents POST /api/medications/intake/dose-history-import and
GET /api/medications/{id}/intake/import/{jobId}/status, including the
full 16-value MedicationImportSkipReason set the v1.33.0 shape widened
to, read straight from MEDICATION_IMPORT_SKIP_REASONS so the two never
drift again.

Also force-registers MedicationDoseHistoryImportFatalReason into
components.schemas: the 422 description names it, but nothing else
$refs it, and zod-openapi drops unreferenced schemas silently.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck 02c6658a35 docs(openapi): add on-demand provider sync and Google Health status routes
Documents POST /api/oura/sync, /api/polar/sync, /api/strava/sync,
/api/nightscout/sync (all four share the resolveSyncOutcome shape) and
GET /api/google-health/sync/status, matching the routes' own
sanitising/clamping logic field for field.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck b7074e04b8 docs(openapi): add GET/PUT /api/auth/me/report-selection
Documents the saved doctor-report scope endpoint, reusing the real
savedReportProfileSchema so the spec's leaf cap and version literal
stay tied to the source of truth.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck ec2c8a9e71 docs(openapi): publish documentOnly on share-link create and list
The field has been served on both routes since v1.28.13 but was
missing from ShareLinkCreated and ShareLinkList, which are
additionalProperties: false.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck c9c824ca4d Publish share-selection group membership in capabilities
GET /api/meta/capabilities dropped group membership at the wire: it
shipped a flat group-id list and a flat leaf list with no boundary
between them, so a native client could not tell which leaves live
under which group, or which group is the fenced sensitive tier. That
made it unsafe for the client to offer a "select all" affordance for
any group, since it could not mirror the server's fence against
bundling more than one sensitive leaf behind a single control.

Add SHARE_GROUPS to the report-selection catalogue, derived from the
existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP
maps: each group now carries its member leaf ids, and the group whose
id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in
the capabilities response now ships this shape instead of bare ids;
share.leaves is unchanged. Update the OpenAPI schema and regenerate
docs/api/openapi.yaml.

Add an invariant test asserting every leaf in the flat leaves set
appears in exactly one group and no group carries a leaf outside that
set, plus a check that exactly one group is flagged sensitive.
2026-07-30 19:54:49 +02:00
Marc-André Bombeck 9a5206ee5f docs(openapi): give rhythm-events its own six-value classification enum
GET /api/insights/rhythm-events was never in the spec, so a client had
nothing to generate a type from but the three-value ECG classification
next door — reusing it would drop VERY_LOW / FIRED. Register the route
with its own RhythmEventClassification carrying all six
RhythmClassification values; the ECG enum stays untouched at three.
2026-07-30 16:06:53 +02:00
Marc-André Bombeck 00c282d311 fix(workouts): collapse a re-sent session on every surface, not just the list
The canonical picker keyed a session on sport, start, end and duration. Only the
workout list selected the end timestamp; the Coach narration, the per-sport
baseline and the deep-link cluster passed rows without it, so those rows carried
no session identity at all and the duplicate survived. One record counted once in
the list and two or three times everywhere else, including in the averages a
person is compared against.

Drop the end timestamp from the identity. On a workout with pauses it measures
wall-clock time while the duration counts only moving time, so two re-sends of
one session can differ in that field alone and the stricter key rejected a
genuine match. It also bought nothing in exchange: nobody starts a second
workout of the same sport in the same instant with the same duration.

The deep-link cluster now selects the duration it needs. A structural guard
freezes the call-site set and records which file owns each caller's select, so a
new surface cannot join the set without fetching the identity, and the identity
cannot grow a field only some callers fetch.
2026-07-30 11:54:58 +02:00
Marc-André Bombeck c83254032e fix(google-health): map badminton workouts 2026-07-29 22:24:04 +02:00
Marc-André Bombeck 0c7b958b1b fix(workouts): collapse repeated same-source sessions 2026-07-29 21:30:52 +02:00
Marc-André Bombeck 271f0ed28a fix(medications): make import replay results honest 2026-07-29 16:11:45 +02:00
Marc-André Bombeck 5b695f43ea fix(analytics): restore device-priority sleep selection 2026-07-29 16:08:41 +02:00
Marc-André Bombeck fb02cf89b1 fix(jobs): reconcile durable fact inventory 2026-07-29 15:59:27 +02:00
Marc-André Bombeck 75aa850eae style: satisfy v1.34.1 format gate 2026-07-29 15:59:12 +02:00
Marc-André Bombeck fb9ae2ac61 fix(coach): keep stateless turns grounded 2026-07-29 15:56:19 +02:00
Marc-André Bombeck 9354c649fe fix(integrations): surface Google sync progress and canonical workouts 2026-07-29 15:55:37 +02:00
Marc-André Bombeck b9879e8829 fix(nightscout): pin approved private origins 2026-07-29 15:54:01 +02:00
Marc-André Bombeck 8db21ab718 fix(jobs): persist bounded success outcomes 2026-07-29 15:50:42 +02:00
Marc-André Bombeck b41b32d472 feat(i18n): complete v1.34.1 locale reconciliation 2026-07-29 15:11:40 +02:00
Marc-André Bombeck 5bc213c5b8 test(01-21): type guarded progress mock calls
- declare the updateMany mock argument tuple
- assert the guarded write exists before inspection
2026-07-29 15:04:08 +02:00
Marc-André Bombeck 104a058096 style(workouts): format pulse enrichment 2026-07-29 14:58:46 +02:00
Marc-André Bombeck 5ec1a3836f feat(01-21): add fair Google sync outcomes
- finish workout and low-cost resources before dense metrics
- report bounded terminal outcomes and invalidate workout caches
2026-07-29 14:58:40 +02:00
Marc-André Bombeck d2f6bf33eb fix(workouts): sanitize WHOOP zone enrichment 2026-07-29 14:58:24 +02:00
Marc-André Bombeck 89fefce7ac test(workouts): separate pulse aggregates from curves 2026-07-29 14:57:42 +02:00
Marc-André Bombeck 43cce08012 fix(workouts): share enriched canonical detail data 2026-07-29 14:56:59 +02:00
Marc-André Bombeck 635e9b64c5 fix(workouts): enrich canonical WHOOP aggregates 2026-07-29 14:55:11 +02:00
Marc-André Bombeck 1cb0f7dd6a test(workouts): pin WHOOP aggregate enrichment 2026-07-29 14:53:59 +02:00
Marc-André Bombeck c3ffc43335 refactor(01-22): reuse Google cohort concurrency limit
- preserve the shared Google Health poll fan-out width
2026-07-29 14:53:22 +02:00
Marc-André Bombeck 6a3861122c feat(01-22): publish truthful cohort outcomes
- fold complete partial failed parked and skipped user verdicts
- bound serialized job facts to a privacy-safe scalar allowlist
- preserve provider retry isolation and reminder queue completion
2026-07-29 14:50:30 +02:00
Marc-André Bombeck 5cfcb69ef2 feat(01-21): add guarded Google sync progress
- persist one bounded owner-scoped current-run envelope
- reject stale writers and materialize interrupted runs safely
2026-07-29 14:49:12 +02:00
Marc-André Bombeck addd6b7000 fix(01-19): avoid phantom ECG failure counts
- increment archive-level failures only for discovered ECG artifacts
2026-07-29 14:47:47 +02:00
Marc-André Bombeck a686c7200b feat(01-19): persist Apple Health ECG recordings
- encrypt and upsert stable content-addressed ECG recordings transactionally
- keep XML imports successful while ECG members fail with scalar counters
2026-07-29 14:45:40 +02:00
Marc-André Bombeck f06137c0db feat(01-19): add bounded Apple Health ECG parsing
- validate ZIP paths, compression metadata, and actual decompressed bytes
- normalize ECG CSV descriptors and bounded microvolt samples
2026-07-29 14:42:18 +02:00
Marc-André Bombeck efe203212a fix(01-20): classify safe assessment outcomes
- add a discriminated generated and fallback contract
- screen derived prose and expose retryable cache policy
2026-07-29 14:36:29 +02:00
Marc-André Bombeck 0ae9165aae refactor(01-18): format occurrence identity changes
- apply repository formatting to reminder and scheduling updates
- preserve verified occurrence behavior without semantic changes
2026-07-29 14:36:21 +02:00
Marc-André Bombeck 840e47fda5 fix(01-18): preserve authoritative rolling due in consumers
- expose the canonical due schedule identity from the shared list read
- prevent passed local windows from rebinding a prior overdue occurrence
2026-07-29 14:35:23 +02:00
Marc-André Bombeck 2de9bccf9d fix(01-18): retain rolling occurrence through due band
- preserve prior rolling identity independently of local-day flooring
- scope exact resolution by schedule and era while advancing resolved grids
- include open rolling slots for display without widening compliance bands
2026-07-29 14:33:17 +02:00