The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.
The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.
A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.
It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
The last-attempt lookup runs on the zero-rows path, which is exactly the path a
fresh account takes. When it threw, the whole nutrients read returned 500 — so an
account with nothing to show got an error instead of an empty page, and the
feature that exists to explain an empty surface was the thing preventing it from
rendering.
Wrapped so a failed ledger read degrades to the plain empty state and is recorded
for the operator rather than shown to the person. The route's shape assertion now
carries `lastAttempt: null` explicitly, so a field cannot join this response
without someone deciding it should.
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.
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.
The existing test encoded the behaviour this release deliberately changed: the
breadcrumb used to fire only when a row landed, which made the one durable trace
of an ingest conditional on its success. It failed for the right reason.
Rewritten to assert the new intent with the reasoning kept, and it goes red again
if the condition returns.
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.
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.
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.
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.
SubPageShell insets its own heading to the card body edge rather than the
container edge, so a section heading or a meta line rendered flush left sat
16 px to the left of everything above it. The day-context heading, the sport
chips and the count line now take the same responsive inset.
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.
The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
The bpm range under each effort zone sat at an arbitrary text-[10px]. It is
a micro-label on a card, not a chart axis, so it takes the text-2xs token
the standards mint for exactly this.
"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.
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.
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.
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.
Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.
The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.
A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
nutrient.batch.ingest only reached the durable AuditLog table when
inserted + updated > 0, so a batch where every entry was rejected
(unit mismatch, out-of-range value, invalid day, a write failure) left
the exact same empty trail as a phone that never called the endpoint
at all. That made an empty nutrient_intake_days table unanswerable
from the durable record - the question this surface exists to answer.
Record the call unconditionally, the same shape measurement.batch.ingest
and workout.batch.ingest already use (counts only, no per-entry health
values, no user-typed content), plus an aggregate skip-reason breakdown
so an operator can tell, from the row alone, that the phone posted and
why nothing landed.
HTTP status and the per-entry response shape are unchanged - a batch
endpoint returning 200 with per-entry outcomes on a partial failure is
the correct, documented contract and native clients already decode it.
The page's overview query shares queryKeys.nutrientIntake(30) with
MicronutrientsCard, which already renders its own QueryErrorCard on a
failed read. But the page's empty-state check ran first and treated
undefined data (the failed-read state) the same as a genuinely empty
window, returning "no nutrient data yet" and short-circuiting before
any of the three cards - including the already-correct one - ever
mounted. A self-hoster with HealthKit permissions granted and a read
that actually failed saw the same blank invitation as someone who
never logged anything.
Add the isError branch above the empty-state check so a failed read
surfaces its own QueryErrorCard with a retry, and cover it with a test
that renders the actual page (not just the card in isolation) so a
regression here can't hide behind a passing unit test again.
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.
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.
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.
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.
The field has been served on both routes since v1.28.13 but was
missing from ShareLinkCreated and ShareLinkList, which are
additionalProperties: false.
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.
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.
On a phone the composer stacked its controls under the textarea, so an EMPTY
input was two rows tall: a text line, a 44 px tap-target row, and padding. That
is roughly a tenth of a phone screen spent before anyone has typed anything, and
it is the first thing on the screen when the Coach opens.
The auto-grow was never the problem. The textarea already starts at one line and
grows to six; it was the layout around it that reserved the second row up front.
The stacking existed to protect keyboard focus order. That argument does not
hold: focus follows DOM order, and the DOM order is textarea, actions, Send in
both layouts. Nothing jumps. `items-end` keeps the controls beside the last line
as the textarea grows, so the send button stays where the thumb left it.
One component serves both surfaces — the conversation view and the hero, which
re-parents the same live composer — so this is one fix rather than two.
The test that asserted the stacked layout now asserts the row, with the reason
written down, and it fails when the stack comes back.
The header fix needs two declarations to hold together: the second grid row is
conditional on a description, and CardAction spans the full row set. The second
is the trap. `row-span-2` against a single explicit row mints an implicit second
row and brings the phantom gap straight back for every header that carries an
action but no description.
Reverting it broke nothing. Nothing in the suite asserts a `row-span` class, so
the regression is DOM geometry the unit tests cannot see, and it would have come
back silently at the next tidy-up. This is a literal-class assertion in the shape
`assessment-spine-order.test.ts` already uses, because the question has an exact
answer.
Also finishes the compact-card tier while it is open: three cards on the metric
surfaces still carried `gap-1.5` where the assessment cards carry `gap-2`. One
tier, one value, no `gap-1.5 py-3` left in the tree.
grid-rows-[auto_auto] was unconditional, so a header with a title and
no CardDescription still reserved an empty second row track and drew
the gap-2 for it — 32px instead of the intended 24px between heading
and body on desktop. Make the second row conditional on a description
being present, and widen CardAction to row-span-full so it still
tracks the header's row count when an action lands in a
description-less card.
Also correct two assessment cards that had drifted onto gap-1.5
instead of the sanctioned gap-2 compact tier.
The navigation-geometry spec failed on a merge commit whose tree was
byte-identical to the branch head that had passed minutes earlier, so nothing had
regressed. The spec resolved the element it measures with
`main.firstElementChild`, independently in each of its two `page.evaluate` calls.
Any node mounting ahead of the wrapper between the two reads made it measure two
different elements, which is why the failure was 64 against 131.92 rather than a
two-pixel drift. A timing problem gives a small difference or a null; this was a
substitution.
The shell's content wrapper now carries a stable `data-slot`, both reads select
it by that, and the wait gates on the element rather than on scroll state alone.
The same positional pattern in the overscroll spec is fixed with it, so the class
is gone from the measurement paths rather than from one spec.
Re-running the job made it green, which is a workaround. This is the fix.
The card carried a four-sentence non-medical notice at the top of the cycle
on-ramp. It goes, at the maintainer's decision.
The goal-specific caveat on the prediction surface is untouched: a profile set to
avoid pregnancy still gets the stronger "not a contraceptive method" copy, which
is chosen server-side by goal and shared with the native client, so nothing about
that path changes here.
Removes the key from all six locales and the now-unused icon import.
The service worker derives its cache names from the version injected by
`sw-version.js`, and falls back to a literal compiled into `sw.js` when that
import fails. The prebuild step rewrites both from `package.json`, so both were
correct as long as a release rebuilt. One did not, and the literal trailed the
release by a version while every test stayed green.
Nothing checked it. The generated file is compared against `package.json`, and
the behavioural suite deliberately reads the literal out of the source so its
cache-name assertions cannot drift from the literal they exercise. Reasonable
apart, and together they left the literal itself unverified.
The effect is confined to the degraded path and is still worth closing: a client
that cannot load `sw-version.js` computes the previous release's cache names, so
it keeps serving the old shell and its cleanup treats the current caches as the
stale ones.
Re-anchors the literal and adds the equality as a gate, since it is a question
with an exact answer. A version bump committed without a rebuild now fails
locally instead of shipping.
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.
The spacing pass on this branch tightened `insight-status-card` to `gap-1.5`
and left `mood-insights-sections` at `gap-2`, so two sibling cards that are
meant to read as one family stopped matching. `assessment-spine-order.test.ts`
exists to hold exactly that contract and went red as soon as the formatter
stopped masking it: `format:check` failed first in the job, so the unit tests
never ran and the divergence shipped unnoticed onto the branch.
Bring the mood card to the same value rather than widen the other one back.
The direction of travel is the tighter rhythm, and both allowed steps are on
the sanctioned spacing scale.
The test's expected string moves with the contract, not around it: it still
asserts that both cards carry the SAME value, which is the whole point of it.
The formatter joins one JSX line that was left wrapped. No behaviour change.
It is the only thing standing between this branch and a green gate: typecheck,
tests, e2e, integration and both image builds already pass, and `format:check`
fails on this file alone.