The hero card composition was fixed: score panel first, the worth-a-look
rail beneath. A reminder-driven person asked for the reverse. The stored
dashboard layout gains an optional hero field ('score' default,
'reminders' promotes the rail into the hero slot), validated as a closed
enum, preserve-when-absent on write, omitted from the blob at the default
so untouched accounts serialize byte-identically. The choice sits in
Settings beside the other hero controls; the promoted rail degrades to
the calm all-clear line when nothing is due.
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.
`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.
`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.
`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.
`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.
`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.
`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.
Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.
The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.
Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.
Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.
The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.
A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
Server: a write with the current base token succeeds and returns the advanced
token; a stale base returns 409 and writes nothing (no unconditional fallback);
a malformed base 422s; an omitted base keeps the unconditional write. Client:
the ring payload never carries widgets and threads the base token, the shared
busy gate covers either write, and the Save/ring wiring is source-pinned.
Refs #581
A dashboard layout change could be silently reverted when a hero score-ring
toggle was made around the same moment. Both writes PUT the whole layout to
/api/dashboard/widgets, and whichever reached the server last won, so a ring
write built from a pre-save snapshot overwrote a fresher Save.
Guard the write with optimistic concurrency. The client sends the updatedAt it
based its edit on; the route writes conditionally on the stored row still
carrying that token (updateMany where updatedAt = base) and returns 409 with a
typed body on a stale base, writing nothing. The advanced token rides back on
every GET/PUT response so the client can rebase. No migration: the User row
already has an @updatedAt column.
On the client, the ring PUT carries only its own fields plus the base token,
the Save and ring writes share one mutation scope so they serialise, the Save
button and ring controls disable while either write is in flight, and a 409
refetches the winning layout and asks the user to redo the change.
Refs #581
The Settings page's instant score-ring toggle PUT the full dashboard
layout, built from the client's cached `remote` snapshot. That snapshot
can go stale mid-flight: if a normal tile/chart Save committed a newer
layout first, the ring request's explicit stale `widgets` (and
`comparisonBaseline` / `chartOverlayPrefs`) still won on write and
silently reverted the just-saved layout whenever it resolved last.
The ring mutation now PUTs only the two ring fields
(`buildRingMutationPayload`). `widgets` joins the existing
preserve-when-absent merge (`LAYOUT_FIELD_MERGE_DISPOSITION`) so an
omitted field is carried forward from whatever is currently stored
instead of a client-held copy, closing the race regardless of request
order.
Both metrics exist in the record but the summary route emitted neither,
so a native client could render every home-screen tile except those two.
Mood reads the canonical daily series (`buildMoodDailySeries`) — the same
rollup-backed aggregation the web mood analytics and the dashboard
snapshot render — and emits the newest day's score with a trailing
seven-day sparkline. BMI comes from `computeBmi`, the one implementation
the web vitals dashboard already renders through the derived-metric
dispatch; the route resolves the figure so no client re-derives weight
over height squared.
Mood gates on the mood module through a synthetic `MOOD_ENTRY` key, the
same route `NUTRIENT_WATER` takes, because mood is not a measurement
type; without the join the card would ship past a disabled module. BMI
belongs to no module and rides through with the other core vitals.
The BMI card is omitted rather than emitted empty when the profile has no
height or no weight in the window: the card shape carries no honest
"unknown" state, and a null value would read as a logging gap rather than
a missing profile field.
The layout PUT preserved chartOverlayPrefs, selectedScoreRings and
heroRingOrder when a client omitted them, but the top-level
comparisonBaseline was not in that merge. serializeDashboardLayout clamps
a missing baseline to "none", so a layout save from a client that does
not know the field — the native client treats it as web-only and never
sends it — silently reset the comparison the user had picked on the web.
No error, no audit row.
Replace the hand-maintained three-field merge with an explicit
disposition map over every top-level layout field. The map is declared
`satisfies Record<keyof DashboardLayout, ...>`, so adding a layout field
without deciding replace-or-preserve fails typecheck; the preserve set
and the merge are derived from it rather than restated. The stored-layout
read is skipped when the client sent the full set.
Also catalogue the four clinical signals the native client pins — grip
strength, pain NRS, waist circumference, waist-to-height. They reached
the route as unknown ids, were filtered out ahead of Zod, and vanished
from the persisted layout, so a tile placement was lost on every save.
They join the iOS-only set because the web dashboard has no render path
for them; their charts live on the insights sub-pages. Raise the widget
array cap to 50 to clear the now-42-id catalogue.
The inventory had a BUILDER-GATED bucket that accepted the mere presence
of an aggregator's name -- loadFhirContext, collectDoctorReportData -- as
proof a route was gated. That proved nothing. loadFhirContext filtered
per-domain modules but never evaluated doctorReport, so all five
/api/fhir data routes sat in a green bucket while serving the whole
record to an account that had the module off.
Drop the bucket. A route now counts as gated only when it calls a gate,
and the orphan message says plainly that importing a module-aware builder
is not one.
Add two assertions the old shape could not make. REQUIRED_TREE_MODULE
pins a tree to the key its routes must name, so /api/fhir cannot satisfy
the inventory by gating on some other, more permissive module.
BEHAVIOURAL_GATE_TESTS lists the tests that actually exercise a disabled
module end to end and fails if one goes missing, since this file reads
source text and can never prove a refusal itself.
Verified by removing the gate from the Patient route with the builder
import left in place: the old inventory passed, this one names the file.
GET /api/dashboard/summary builds its own payload instead of going
through readDashboardSnapshotCached, so it never picked up the module
gate the shared snapshot builder applies. It emitted glucose and sleep
cards on data presence alone while /api/dashboard/snapshot correctly
withheld them for the same account.
Lift the drop decision out of gateSummariesByModules into
disabledSummaryTypes in the client-safe widget-modules module, so the two
dashboard aggregates decide from one map. The snapshot keys its summaries
by MeasurementType and the summary route emits cards keyed by an iOS
MetricKind, so what is shared is the decision, not the filtering;
SUMMARY_METRIC_TYPE_BY_KIND is the join between the two.
Omit rather than refuse, matching the snapshot sibling: the payload spans
ten cards across several domains, and blanking the dashboard because one
module is off would be the wrong trade. Core vitals always pass through.
The filter runs outside the analytics cache on purpose. That LRU is keyed
by user with a 60 s fresh / 1 h stale window and a module toggle does not
evict it, so gating inside the builder would keep serving a disabled tile
for up to an hour after the user turned it off.
Wire shape unchanged -- a gated card is absent, not null.
rangePoints lived in plain useState(30), so every chart reset to the
30 d tab on mount while the sibling trend/comparison/target-band
toggles on the same card persisted per chart. Add rangePoints to
ChartOverlayPrefs (server-validated against the closed 0/7/30/90 set)
and hydrate it once the prefs resolve; a range-tab tap now persists
through the same PUT the toggles already use.
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.
- Drop the DashboardHero / DashboardHeroSkeleton mount from the
dashboard page; the header now renders the greeting unconditionally
(its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
defaults, resolver, serializer) and the widgets PUT schema. The
resolver is a whitelist constructor, so a stored heroVisible is
dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
carousel, briefing spotlight) and their tests; drop the retired
dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
score-ring picker no longer hangs off that flag and stays available,
since the rings it configures still ride the server-computed
dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
The sleep source-discrepancy annotation now reaches the surfaces: SleepNight
carries the MAIN session's annotation (naps never cross-compare), the analytics
slim slice / dashboard snapshot / iOS summary thread it additively, and the
dashboard sleep tile renders the shared discreet marker (extracted from the
sleep panel — one implementation, two surfaces). Served totals unchanged,
pinned by tests.
Apple Health medications groundwork (server side of the medication-import
request): IntakeSource gains APPLE_HEALTH; medications accept an external
mirror identity (source-exclusive by policy) and dose imports carry the HK
dose-event UUID — both idempotent per stable key, with the concurrent-create
race resolved to the winner. Migration 0239, additive only. Entries without
the new fields behave byte-identically.
Hardening: doctor-report aggregation + PDF charts compute ranges with loops
instead of spread calls — a report over a sample-dense year cannot overflow
the call stack (the class fixed for the Google Health full sync).
Add resolveHeroRingOrder unit coverage (default health-score-first,
reconcile against selected set, drop/append, dedupe) plus resolver/serializer
round-trip; hero SSR tests for the default lead + a persisted custom order +
dropped-when-unrendered; Settings picker tests for the pinned health-score
anchor row, its locked-on switch, drag/arrow reorder, and a persisted
non-leading order. Extend the score-rings picker description across six
locales.
Add a persisted hero ring ORDER over the health-score anchor ring plus the
selected score rings. Defaults to the health-score ring on the leading edge;
the user can reorder the full set (health score included) from Settings →
Darstellung with the existing drag-handle + arrow-button pair.
The order rides the dashboard-layout blob (dashboardWidgetsJson) — no
migration. resolveHeroRingOrder reconciles a stored order against the current
ring set on every read/serialize, so a deselected ring drops and a
newly-selected ring appends; the hero orders only the rings it actually
renders.
The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.
src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.
Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.
H4
Add selectedScoreRings to the dashboard layout blob: a closed four-id
set (READINESS, RECOVERY_SCORE, SLEEP_SCORE, MED_COMPLIANCE) capped at
three rings, with a client-safe ring-to-module map alongside. The
resolver drops unknown ids, dedupes, and clamps; a legacy blob without
the field defaults to the single MED_COMPLIANCE ring while an explicit
empty array is respected. The widgets PUT validates the field against
the closed enum and preserves the stored value when a client omits it,
the same contract heroVisible already rides.
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.
Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
The hero score card and the daily briefing accept tension, returnToBand, and
memory props, but nothing populated them server-side, so the lines never rendered.
Resolve all three server-authoritatively on the dashboard snapshot DTO (iOS rides
the same payload):
- tension — fires when the readiness composite's contributors disagree (a strongly
favourable one alongside a strongly unfavourable one), reusing the readiness
components the wellness ring grades and the shared disagreement detector.
Suppressed under a clinical red-flag (a coincident-deviation that no illness
explains) so the red-flag path is never reconciled into a calm verdict.
- returnToBand — runs the streak detector across the salient vitals and surfaces
at most one metric that has come back inside its own personal range after a
genuine prior out-of-band run.
- memory — maps the machine-derived coach memory block to the briefing's recall +
forward-look.
The blocks are locale-agnostic (contributor keys + a metric type); the insights
hero localises them through the existing readiness-contributor and metric-name
labels before handing the cards their resolved strings. Each value omits rather
than guesses. The snapshot cache key now carries the locale for the recall prose.
The cumulative model summed every nightly deficit over a 14-night window
with no credit for catch-up sleep, so a sleeper a modest amount short on
most nights carried hours of standing debt that never recovered while the
pattern held. Replace it with a running balance over a 5-night window:
short nights add their capped shortfall, nights above need pay the balance
down at a sub-linear recovery rate, floored at zero. The headline now
reflects what is still owed tonight and shrinks as the sleeper catches up.
Achievements: the bucket was 60 s hard-TTL while the app-wide unlock
notifier polls every 2 minutes, so every poll landed on an expired
entry and fired a cold ~400 ms rebuild in the foreground. Add a
10-minute stale-while-revalidate window and read the route through
cachedSwr; the route already persists pendingUnlocks outside the cached
factory, so a stale serve never skips an unlock write.
Achievements builder: the vitals array ran through several full-array
passes, each re-deriving a Berlin day-key (and hour) per row via
Intl.DateTimeFormat. Compute each row's day-key once and thread the
parallel array into the green-day, weekly-consistency, engagement, and
hidden passes; the hidden pass reuses a single precomputed hour array.
Results are byte-identical to the per-row derivation (parity test added).
dashboard/summary: read through cachedSwr so the iOS-polled endpoint
serves its existing 1 h stale window instead of re-paying the cold
rebuild every 60 s on the plain cached path.
Charts: route the three recharts consumers that bypassed the shared
next/dynamic boundary (lab biomarker chart, sleep stage stacked bar,
recommendation rationale charts) through dynamic imports with matching
loading skeletons, taking recharts off the first-load JS for
/labs/[id], /insights/sleep, and the insights recommendations surface.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Make computeSleepRhythmFromNights own the chronotype look-back so the dashboard
summary (a year of nights) and GET /api/sleep/rhythm (six weeks) return the
same band, mid-sleep, and social-jetlag values rather than averaging different
samples. Derive the free/work day-type from the local wake-day digits so the
classification holds in far-east timezones, and read the shared sleep-rhythm
DTO type into the client hook instead of a hand-mirrored copy.
Compute cumulative sleep debt and the MCTQ chronotype server-side off the
canonical night reconstruction the Sleep Score reads, and render them on
the sleep page: a sleep-debt headline with a calm partial state under the
night threshold, and a chronotype card whose social-jetlag detail and
corrected mid-sleep sit behind an advanced disclosure. Both hold a learning
state on thin data and never assert a total or a band.
Serve the same values to the dashboard summary and a new GET /api/sleep/rhythm
read so the native client renders identical figures. Free vs work nights
default to weekend in the user's timezone until day-type tracking exists.
The hero renders only when the layout carries the literal
heroVisible: true. Legacy blobs without the field, malformed values,
and fresh accounts all keep it off; the Settings toggle persists an
explicit opt-in through the existing layout PUT.
The snapshot's fast phase gains a medsToday block — active count,
today's scheduled/taken/skipped tally after the idempotent projection,
and the earliest server-computed next due across active medications —
and the warm phase derives a compact health score from the coverage
map and graded blood-pressure windows the extras build already holds.
A pure verdict resolver ranks the day's single highlighted item over
nine rungs from an overdue dose down to a quiet-day fallback, with
fixed crisis floors for the blood-pressure rung and timezone-correct
day checks. The layout grows a heroVisible flag riding the stored
widgets JSON, the iOS summary route adopts the shared dose-state
builder with an unchanged wire shape, and the new sub-blocks report
their build time alongside the existing snapshot timings.
Staleness can re-enter a generated text: measurement age is hashed as a
bucketed tier, so the fingerprint stays stable day to day but flips
exactly when a reading crosses the boundary the prompt caveat cares
about. The ingest debounce becomes a one-hour minimum gap with the
hash gate carrying the budget, so a notable morning reading is
narrated the same morning instead of after the next nightly run. A
manual regenerate now refills the status cards through the same
hash-gated pipeline — changed cards regenerate, unchanged cards
refresh free, no eviction touches the hash baselines. The user's
self-context joins the comprehensive fingerprint so a newly remembered
fact reaches the briefing, the sleep writer collapse covers every
caller that reads nights, the batched compliance build fans out
bounded instead of sequentially, and a failed compliance read renders
a quiet retry state in the exact skeleton footprint instead of
swallowing the error.
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
The dashboard snapshot lives in the analytics cache bucket, which had no
stale window, and every measurement write hard-evicted the whole
${userId}| prefix. A high-frequency iOS Apple-Health sync therefore
busted the snapshot into a cold rebuild on the next read all day long
(the same class as the v1.8.7 insight-cache eviction).
Give the analytics bucket a stale-while-revalidate window, read the
snapshot via cachedSwr, and switch the measurement-write invalidation
from a hard deleteByPrefix to markStaleByPrefix. The snapshot now serves
the prior value immediately while a single background recompute warms a
fresh one. Slim / thick / summary cells read via plain cached are
unaffected: a marked-stale entry has expiresAt === now, so their next
read is a clean miss and rebuilds fresh. Widget reorder keeps its hard
point-evict so a layout change paints synchronously; mood and medication
writes keep their hard analytics sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-stage SLEEP_DURATION rows carry the stage segment's END instant, so a
night where the user falls asleep before local midnight spread its stages
across two calendar days. Keying each stage by its own day split one
overnight sleep into two partial nights, and the headline last-night tile
showed only the post-midnight slice.
Reconstruct nights by clustering stage segments into sessions: a new session
starts only when the next segment's start (end minus its minute duration) is
more than three hours after the running session end. A single long stage
block no longer splits a night, a daytime nap stays separable from the
following overnight block, and the gap is measured on the absolute instant so
the clustering is DST-immune. Each session is keyed by the local wake day of
its latest segment end, matching the convention that a sleep session belongs
to the morning you wake up.
Collapse a multi-source night to one canonical source before summing, using
the user's sleep source-priority ladder (default WHOOP > Apple Health >
Withings), so a dual-source account no longer double-counts the total. Sources
are never blended within a night.
Bound the series sleep read to one year even on the 'Alle' range, matching the
slim slice's sleep window, so a multi-year dual-source account does not walk a
six-figure stage-row set into JS.
Add fixtures straddling local midnight, crossing a DST spring-forward, keeping
a nap separable, and collapsing a dual-source night.
SLEEP_DURATION is stored in minutes, one row per sleep stage per night
(IN_BED / AWAKE / ASLEEP / CORE / DEEP / REM) since the HealthKit
category-sample ingest landed. The value is not cumulative, so reading
the single most-recent row surfaced one stage's minutes — never the
night. The dashboard sleep tile and the sleep series therefore showed a
fragment of the night rather than the night.
Add a shared `summarizeSleepNights` helper that groups the per-stage
rows by the wake-day calendar date in the user's timezone and sums the
asleep stages (CORE + DEEP + REM, plus a legacy bare-duration row),
excluding IN_BED and AWAKE per the AASM convention already used by the
sleep score. Wire it into:
- the dashboard summary tile, which now emits last night's time asleep
in hours with an explicit `unit: "h"` and a per-stage breakdown for a
future detail view;
- the sleep series, which now returns one point per night (time asleep
in hours) plus a top-level `unit` for every kind so clients never
infer the unit;
- the slim summaries slice, so the web dashboard tile and the insights
overview read per-night totals (kept in canonical minutes there; the
web tile converts to hours) — strictly more correct than averaging
across stage rows, and parity with the iOS value.
The today-window projector minted a single pending intake row per
schedule, anchored at `windowStart`, dropping a schedule's first-class
`timesOfDay`. A twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` therefore surfaced only its morning
dose in the today tile and the "Erfassen" sheet, and the event-count
compliance rollup recorded one expected dose per day instead of two — a
perfectly adherent user read 50% the moment the second dose was logged,
and deleting then re-adding one dose left the rate stuck at 50%.
Fan the projection out over every `timesOfDay` entry, mirroring the
reminder worker's per-slot mint and dedup against the
`(userId, medicationId, scheduledFor, source)` unique index. Absent
first-class `timesOfDay` the projector still emits one slot at
`windowStart`, byte-stable against the worker's legacy single-window row.
Adds regression coverage across the projector (dashboard summary), the
legacy compliance walker, and the cadence expansion pinning the
single-row twice-daily case at the correct two-dose-per-day shape.
The /api/dashboard/summary endpoint returned per-metric `title` and
`unit` as translated German strings ("Gewicht", "Schritte", …), which
injected DE into clients with an English UI. The wire now carries i18n
keys (`titleKey` / `unitKey`) under a new `dashboard.metric.*`
namespace; the PWA's `useTranslations()` and the iOS client's bundled
`Localizable.xcstrings` each resolve against their own locale bundle.
Locale parity preserved across de, en, es, fr, it, pl (DE / EN native,
the rest matched against the existing measurement-type labels). The
locale narrowing in `buildContext` is widened to accept every shipped
locale rather than dropping fr / es / it / pl back to the default — the
greeting + streak label now resolve in the user's configured language.
The OpenAPI yaml drift was a pre-existing 1.5.3 → 1.5.4 version-string
gap; regenerating picked it up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.
Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
aggregate into two parallel queries. The linearly composable columns
(count / min / max / mean) keep the full-partition scan; the windowed
7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
the planner does an index range scan on (user_id, type, measured_at)
instead of a full-partition sequential scan. Output is bit-identical;
cold slim slice drops from multi-second to sub-second on tenants with
large measurement partitions.
Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
Initial category medication.clientManaged: boolean (default false). Cron
medicationReminderJob skips dose-due APNs when the flag is true,
emitting a medication_reminder.suppressed_client_managed wide-event
annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
device APNs token presence (masked), channel config presence, and
recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
priority-10 branch runs. A successful self-test now exercises the
exact path production reminders take; an unsuccessful one points the
user at iOS Settings → Time Sensitive Notifications rather than
appearing to be a server-side bug.
Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
P2025 catch for the replay branch). The previous read-then-delete
pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
cross_user so operators can tell mismatch types apart without DB
shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
redirect to a consistent error surface.
Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
secret / apiKey / authorization / csrfState / nonce, recursive,
case-insensitive. Caught: /api/devices invalid_format issues were
echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
applied to 14 audit-log sites.
Migrations: 0079 (notification_prefs), 0080 (push_attempts).
Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.
Resolves PR #207.
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.
Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
ORDER BY bucket_start DESC) so the trailing N daily buckets per
type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
BodyFat already is — accounts with zero readings ever no longer get
empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
sparse accounts, zero-data accounts, within-7d guards.
Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.
Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
silent-disable gap the wave was conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
Eight touch-disjoint waves landed on develop before this release:
- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
persist helper and wire all seven *-status routes through it, closing
the recurring ~14 s warm response the iOS client sees on dashboard
open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
(export bundle, gamification achievements, doctor-report aggregator)
with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused tx?
params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
formalisation; knip CI gate staged for v1.4.42 flip.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.
pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.
Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.
Waves shipped (each with a phase report under .planning/):
- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
integration contracts close audit Critical Finding #3 (soft-delete
invisibility) across dashboard, analytics, summaries, rollup tiers,
and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
/api/insights/{features,targets,cards} routes plus the
compliance-rollup hook gap closure for bulk-projection mints; the
post-QA reconcile patch closes the six remaining measurement.findMany
sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
queryKey factory-bypass walker test (extended to comparison/ in the
reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
time-sensitive APNs payload (priority 10 + interruption-level)
scoped via a parameterised test that pins all 6 other event-types
do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
append-only invariant pinned in test; revoke-then-regrant leaves
both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
(week/month/year) wired through summaries-slice and
health-score-fast-path with a linear-composability parity test
pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
eliminates a sibling-drift risk; knip whitelist trimmed to three
files pending follow-up.
QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):
- senior C1 — dashboard-suspense-boundaries regex aligned with the
post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
compliance recompute on both /api/medications/intake and
/api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).
Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.
User-facing prose: see CHANGELOG.md.