The two compliance engines disagreed about a deliberate skip. The dose-history
ledger (`tallyComplianceFromLedger`, behind the medication card, the dose
history, the doctor report and the Health Score pillar) has excluded a skipped
dose from the denominator since v1.15.9 — a skip is a pause, not a miss. The
schedule-anchored engine behind the dashboard tile and the adherence storyline
built its denominator from the raw expected-slot count with no skip term, so it
counted the same slot as expected-and-missed. A twice-daily day with one dose
taken and one skipped read 100 % on the card and 50 % on the tile.
The skip term now lands in the engine, not at the call sites: the day's
expected count drops the doses the user skipped, capped at the minted slots,
and a day whose every expected dose was skipped falls out of the buckets the
same way it falls out of the ledger denominator. The schedule anchoring, the
per-day buckets and the duplicate-log cap are untouched — only the skip
treatment moved. `skipped` outranks `autoMissed`, mirroring
`reconstructDoseHistory`, so a dose the auto-miss cron marked forgotten still
counts against the rate.
A new parity test feeds one fixture (twice daily, one taken, one skipped, one
missed) to both engines and pins them to the same figure, so the class cannot
come back through either side; both new cases were confirmed to fail against
the previous behaviour. The tile-engine suite gains the corrected figure.
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.
Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
- The tiered AI context's band reader now falls back to a live grouped
aggregate when a rollup band is empty (the module doc promised
replace-with-fallback from day one; only the replace half existed), so
a coverage-miss or never-folded YEAR partition no longer hands the
model empty history. Far-from-UTC users get the day-grain band keyed
on their own calendar (the same ±3h guard the correlation readers
carry).
- The slim analytics slice splices measurements older than the 5-year
fold window into its all-time count/min/max/mean: the rollup tier
only covers the window, so "all time" silently truncated for long
histories while the live fallback told the truth. A type whose only
rows are pre-fold no longer reads as never logged.
- The daily Coach sweep now charges each ignored day against
surfaceCount and auto-dismisses at three — the cap the schema comment
promised since v1.22 but nothing enforced.
- The welcome gate compares the stored disclaimer acknowledgment
version against the current one, so revised disclaimer text
re-prompts; /api/auth/me carries the version for the client.
- slope90 and anomalyCount leave the analytics wire: slope90 had no
reader anywhere (computed on every request, in SQL twice), and the
slim path could never compute anomalyCount (it always reported 0).
anomalyCount stays as an in-process field on summarize() for the
insights feature builder, which is its only real consumer.
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
The score popover now opens with three sentences before the reference
line: which axis set the number and how far it sat from its boundary,
what the scale means at that boundary, and that the pair behind it is a
recency-weighted average rather than a single reading someone took.
The lines read the basis the grader already publishes, so nothing here
recomputes anything. A pillar that arrives without a basis contributes no
lines at all instead of an empty one.
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
Three things are worth not regressing: a big afternoon nap must not land in
the night's stage buckets, a day without a nap must come out exactly as the
old per-wake-day path produced it, and the two surfaces must name the same
session as the night for the same rows. The last one drives the real
/api/sleep/night handler rather than re-deriving its answer.
The chart's row assembly moved into a plain function so the nap band can be
tested at all; Recharts draws nothing under server rendering.
Covers what the required parameters now make impossible to forget: the
scorer takes the band whole and never widens it, a missing target scores
the bare trend, the resolved yardstick rides the weight component, the
shared builder refuses the reference band that sits under an override,
the snapshot gates its mood pillar on the module map, an explicit target
reaches both the score and the dashboard bands, and weight/BMI record
direction follows the user's own goal.
F1 adds the golden the score has never had: the rollup read branch and
the live read branch scored against one series encoded both ways. On the
canonical one-weigh-in-a-day cadence they agree exactly. On an uneven
cadence they part company, and the test pins that the whole difference is
the per-day mean collapse - the rollup branch equals the live branch fed
those same daily means - with a companion assertion proving the fixture
is not a no-op.
The resolver reads the profile's sex for the body-fat and pulse bands, and
nothing asserted it. A resolver that stopped reading the field, or read it and
defaulted to one side, passed the whole suite. Pin both recorded sides and the
neutral band that OTHER and an unanswered profile share.
A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.
Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.
stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
Related to #585. Investigated the intraday pulse chart's read path
end to end: the ZIP-import raw-sample fold, the live-sync uploaded
10-min bucket fold, their merge, and the chart's render logic. All of
it treats the two ingestion shapes consistently — the chart has no
shape awareness at all, it paints whatever bucket series it receives.
No read-side or display-side defect explains a systematic density
difference between the two ingestion paths.
What was missing is visibility into the write and read sides that
would let a future report actually be diagnosed instead of
re-investigated from scratch. The batch endpoint's wide-event trace
never broke a skip down by reason, and never distinguished an
HR-bucket write from any other entry in the same request. The
intraday route's own trace surfaced only the final bucket count, with
no visibility into which shape (raw sample rows vs uploaded bucket
rows vs folded hourly rows) a day's data actually came from. Both
traces now carry that breakdown, so the next report of "the chart
looks sparser after a given date" is answerable by grep instead of by
re-deriving the whole read path again.
The empty-path guard in the score fast-path checked raw record presence
(any row at all) rather than each pillar's own eligibility threshold —
weight needs 2 readings, mood needs 5 entries, BP needs a paired
reading, compliance needs an active scheduled medication. An account
with some data but no eligible pillar (a single mood entry, a single
weight reading) fell through to computeHealthScore, which summed no
components and returned a raw 0/red score indistinguishable from a
genuinely poor result.
Check the computed result's own components instead of re-deriving the
thresholds, so the guard can never drift from computeHealthScore's
actual eligibility rules. A pillar legitimately computed as 0 keeps its
0 value and is never swept into the null branch.
Closes#582.
Each case fails on the pre-fix code: the rollup tests on an unaligned
recompute window, the batch tests on the older stats: snapshot winning,
and the resting test on a series that stops at the fold horizon.
The suite was not timezone-pinned. CI runs UTC, so a whole class of
day-bucketing defect was invisible to the gate while a contributor outside
UTC saw a red suite on unmodified main. Verified: a UTC+14 host fails five
tests on an otherwise green tree. Pin TZ=UTC in both vitest configs; tests
that need a different HOST zone now set and restore it explicitly.
The resting-pulse fixtures passed offset-less ISO literals, which parse as
host-local, so they could not distinguish the Berlin day-key the
implementation uses from a UTC one — swapping the day-key's zone left the
file green. Build each instant explicitly in Berlin and add cases whose
local day and UTC day genuinely differ; that swap now fails two of them.
Delete the 'DST boundary (spring-forward)' compliance case. It passed no
timezone, so the slot builder took the legacy UTC path where the date
carries no transition, and it asserted totalExpected === 7 — which holds in
every zone and therefore could never see the double-emit its own comment
described. Threading a real zone does not rescue it: the assertion stays
true in Berlin, UTC, UTC+14 and UTC-11 alike. The property it claimed is
already pinned properly in the scheduling suite, which walks
buildCadenceTimeline with an explicit zone across both the 23 h
spring-forward and the 25 h fall-back day.
Refine the go-forward aggregated HR wire contract (iOS #34) from the never-
adopted hourly bucket to a 10-minute UTC bucket, so one contract serves both
the measurements-list de-explosion and the intraday-pulse 10-min curve.
- Batch route accepts stats:HKQuantityTypeIdentifierHeartRate:<10-min ISO-Z>
with avg + min/max, overwrite semantics, malformed-suffix rejection; the
validator is identifier-generic (allowlist) so respiratoryRate/SpO2 can join
later without a grammar change.
- The intraday reader classifies a day's PULSE rows three ways by externalId
shape (raw / uploaded 10-min bucket / server-folded hourly), builds the
10-min curve directly from uploaded buckets with the min/max envelope and
tension detection enabled, and overlays buckets over raw on the one
cutover-straddling day.
- DAY rollup min/max read COALESCE(value_min, value) so a bucket-native day's
band reflects the true intra-bucket extremes.
No schema change (value_min/value_max exist since v1.19.2). Reconciliation is
go-forward cutover; the nightly retention fold handles the raw backlog.
computeBpInTargetPct's same-calendar-day pairing fallback (for readings
whose sys/dia timestamps drift past the 5-minute session window) keyed
on toBerlinDayKey regardless of the caller's actual user. A non-Berlin
user's two same-local-evening readings straddling Berlin midnight (but
not their own midnight) were rejected as a pair, running the in-target
percentage on fewer readings than it should have — precisely the
population the cross-tz runtime guard in bp-in-target-fast-path.ts
force-routes to this same pairing fallback to protect.
Threads an optional tz through computeBpInTargetPct / collectBpPairs /
computeBpInTargetWindows (default Berlin for untouched legacy
callers), wires it through the fast-path's live fallback and the
comprehensive-insights route's own computeBpInTargetPct call — the
other caller with the user's tz already in scope.
Investigated but left the comprehensive route's resting-pulse-proxy
day-key and BP-medication-continuity day-key on their current UTC
convention: both are joined by an exact YYYY-MM-DD key against a
UTC-anchored partner (the mood rollup's bucketStart, and the
aggregator's date_trunc'd BLOOD_PRESSURE_SYS daily series). Switching
only one side to the user's tz would trade "evening readings shift a
day" for "evening readings mismatch their pairing partner" — a
different bug, not a fix. Documented in place; a real fix needs the
mood-rollup / aggregate tiers themselves to bucket per-user-tz.
Two bugs in the S11 intraday pulse seam:
- The PULSE/step reads queried a ±15h/39h padded superset around the
viewed day, then capped the row count ascending. On a dense account
the cap could be entirely consumed by the PREVIOUS local day's
samples before reaching the viewed day at all, truncating the chart
partway through the afternoon. Query the exact local-day window
(DST-aware) instead — the workout-overlap read keeps the wider
padding since it has no cap and a workout can legitimately span a
local-day boundary.
- The resting-baseline PULSE-proxy fallback bucketed every user's
samples by a hardcoded Berlin day, not the caller's own timezone,
shifting the personal floor the tension detector judges the day
against for every non-Berlin user. Threads `timezone` into
`resolveBaseline`'s `dayKeyOf`.
The medication-compliance engine lived in one 2,099-line file with 38
exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard
pillar, per-medication tile, insight targets, doctor report, …). A single
edit intended for one surface could silently shift all of them.
Split along the existing internal seams into `analytics/compliance/`:
parsing, types, adapters, dose-status, occurrences, windows, cycle,
ledger, display. `analytics/compliance.ts` is now a thin barrel that
re-exports every public symbol, so no consumer import path changes. Pure
move — no logic edits; the tally, window ladder, ledger and cycle math
are byte-for-byte the code they were. Tests move alongside under
`compliance/__tests__/` and keep exercising the public barrel.
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.
Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.
The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.
Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
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).
A one-time background pass re-reads each connected account's Google Health
sleep history so nights stored twice before the sleep fix collapse to their
true total (new sleep_repaired_at marker, migration 0238; watermark-safe,
enqueued once at boot per un-repaired connection).
The sleep night read reports when two writers disagree about the same night —
the served total is unchanged, the disagreement rides along as an annotation
(sourceDiscrepancy on the night DTO) so clients can mark the number instead of
silently picking a side.
A GLP-1 medication whose weight has plateaued on the current dose says so next
to the estimated drug level (efficacy tab + insights) — a factual note from the
existing detector, association only, verb-guard covered.
Google Health diagnostics: the sync log now separates a daily-totals read that
returned nothing from one the importer could not parse
(googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's
structure probe reports the raw response envelope key names when a rollup type
parses to zero — a per-account naming drift becomes visible from one report.
- getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today,
a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary
days stay 24h, and a negative-offset zone anchors on the local day.
- dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day
doses land in ONE bucket where the old fixed-Berlin key split them.
- classifyIntakeTiming: an 08:00-local dose reads on_time with the user's
zone and very_late without it.
- hourInTz: reads the wall-clock hour in the given zone.
- hero-strip greeting: resolves the hour in the stored zone, not the
browser clock; existing greeting fixtures re-pinned to explicit UTC
instants mapped to Berlin wall-clock.
Five timezone-correctness fixes across the medication / compliance surfaces:
- getUserTodayBounds now derives both bounds through the DST-safe
startOfLocalDayInTz, so the today window is 23h on spring-forward and
25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
local dose no longer drops off the med cards, dashboard, PWA badge and
reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
per-day rate with userDayKey, so a non-Berlin user's evening dose is
filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
the achievements perfect-day check passes the user's zone and reads the
scheduled minute-of-day in-zone, so a dose far from UTC is graded
against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
include, so a future engine column reaches both surfaces.
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:
- Paused medications. A doctor-ordered pause flipped `active` off and stamped
`pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
and every paused day became a missed dose after resume — collapsing
adherence across the Health Score, the BP gate, the Coach snapshot and the
report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
migration 0211), close the open era on resume, thread the eras through the
compliance context and drop slots inside a paused interval from the
denominator. Every compliance call site now fetches and passes the eras.
- Health Score eras. The Health-Score fast path was the only compliance
caller that skipped the shared schedule select and schedule revisions, so
after a schedule edit it scored past days against the current schedule
while every other surface used the historical era. Switch it to the shared
select and thread revisions (and pause eras) like the canonical path.
- Insight bucketing timezone. The per-metric status series bucketed every
user in Europe/Berlin, so a near-midnight reading landed in the wrong day
and the relative-day phrasing was off for non-Berlin self-hosters. Thread
the resolved user timezone and key off `userDayKey`.
- Correlation significance. User-facing correlations surfaced from five
paired points with strength read straight off the coefficient. Route them
through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
so small-N coincidences no longer render as confident findings.
- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
daily low-percentile proxy) so the label matches the input.
The rollup read path windows the 7/30/90-day regression on
startOfUtcDay(now - N days), while the live-SQL fallback filtered the
REGR_* aggregates on the wall-clock NOW() - INTERVAL bound. An account
crossing a coverage boundary (warm to cold) therefore saw the boundary
day's readings flip in or out of the slope window depending only on
whether the rollup tier was populated, returning a cache-dependent
slope for the same request.
Anchor both cold-fallback paths on date_trunc('day', NOW() AT TIME ZONE
'UTC') - INTERVAL 'N days', the SQL twin of startOfUtcDay, so warm and
cold return identical boundary-day membership. The avg7/avg30/median
windows stay wall-clock since the warm narrow query computes them the
same way.
The summaries slice (computeFromRollups) and the comprehensive aggregator
(buildFromRollups) each ran a narrow live $queryRaw over the raw
measurements partition on EVERY warm-rollup request to fold slope7/30/90
and r2_7/30/90 via REGR_SLOPE / REGR_R2 - the un-eliminated live scan on
the happy path.
Drop the six REGR_* columns from both narrow queries and compose the
windowed slope / r2 (and the population sd via composeRegression) from
the per-bucket accumulators stored in measurement_rollups. The 90-day
DAY buckets are loaded once (already loaded in comprehensive; one bounded
findMany added in the slim slice), source-collapsed to the canonical
source, then summed over the day-aligned 7/30/90-day sub-windows. A
window whose buckets lack accumulators (pre-backfill) composes to null,
the same shape the empty REGR_* FILTER returned, so a transitional read
never returns a wrong partial regression.
median (PERCENTILE_CONT) and the exact |z|>2 anomaly count stay on the
minimal live query - both are non-composable across buckets. The cold
coverage-miss path keeps the live REGR_* regression. Annotate
slope_source:"rollup" + median_source/anomaly_source:"live" so a
regression to the heavy path stays observable (no-silent-cap rule).
The warm narrow query and the cold heavy query each referenced the
canonical-source subquery twice — once in the window_stats CTE and once in
the outer body — so the DISTINCT-ON pick over the live 90-day rows ran twice
per rebuild. Extract canonicalMeasurementsCte (the SELECT body without the
alias wrapper), bind it once as WITH cm AS (...), and select FROM cm m from
both window_stats and the outer aggregate. Output is identical; the dominant
self-join runs once.
canonicalMeasurementsFrom now wraps the same body, byte-stable for the
single-reference call sites (pinned by a new parity test).
Add a covering partial index over (user_id, type, measured_at DESC) WHERE
deleted_at IS NULL (migration 0183). The hot reads all filter deleted_at and
order measured_at DESC, but the base index is ASC and excludes deleted_at, so
every type not served by the two existing partial indexes pays a heap re-check
of deleted_at over its partition. The covering partial removes the re-check for
the DISTINCT-ON-latest, list and series reads.
Cap the cold-fallback all-time aggregate in summaries-slice at a 15-year
window. It sits far beyond any real history, so it never changes a real
all-time count/min/max/mean; it exists only so a coverage-miss cold read can't
trigger an unbounded full-partition scan.
The raw BP pulls in the comprehensive aggregator and the bp-in-target live
fallback read every source's row for BLOOD_PRESSURE_SYS/DIA. BP is a
multi-source metric — a Withings reading mirrored into Apple Health is two
rows for one event — so each shared reading was counted twice in the
in-target denominator and twice in the recency-weighted grade. The rollup
branch of the same fast-path reads source-collapsed measurement_rollups, so a
rollup-warm account and a rollup-cold account returned different BP
percentages and grades for identical data — a web/iOS and cold/warm parity
break.
Route both raw pulls through canonicalMeasurementsFrom so the cold/live path
picks the ladder-winning source per (type, day), matching the warm/rollup
path. Per-event rows of the winning source survive the collapse, so the
downstream 5-minute pairing still sees individual readings.
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.
The targets route held a ~1.4k-line domain function in the handler body.
Move buildTargetsResponse into src/lib/targets/build-response so it sits
beside the other targets helpers and can be unit-tested without the route
shell; the route is now auth -> cachedSwr(buildTargetsResponse) -> apiSuccess.
Follow the as-needed compliance source pin to the new file location.
The per-day compliance series in the medication-compliance insight snapshot
divided every event-day by the schedule count, treating each calendar day as
fully dose-due. A weekly-cadence medication (a Monday-only schedule, a
bi-weekly interval) therefore had its series read off-cadence weekdays as
misses, contradicting the canonical compliance30 value shown in the same
snapshot.
Route the per-day series through the unified dose-history ledger that already
backs compliance7 / compliance30: add dailyComplianceRatesFromLedger, which
groups the ledger's scheduled slot rows by day and scores each day with the
same taken / (taken + missed) tally — on-time-plus-late numerator, skip /
ad-hoc / upcoming exclusions — that tallyLedgerRows applies to the window
rates. Only days the cadence actually expected a dose produce a point, so a
non-due day can no longer be counted as a miss, and the per-day series and
compliance30 are computed from one ledger and cannot disagree.
The insight generator now derives both the canonical compliance results and
the ledger from a single buildMedicationComplianceBundle pass instead of two
calculateCompliance calls plus a hand-rolled per-day map.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Personal Health Score weighted the 0.20 mood-stability pillar
unconditionally — the fast-path read mood entries regardless of the
account's module state, so a user who turned the mood module off was
still scored (and silently penalised or rewarded) by a pillar hidden
everywhere else in the product. The dashboard already blanks the mood
tile and the coach snapshot unions mood out; the score ring and the
insights card pillar list did not.
Thread a default-on `moodEnabled` flag through the fast-path. When the
caller resolves the mood module as disabled the helper skips the mood
read entirely and leaves the series empty, so `moodStability` returns
null and the existing proportional null-redistribution re-scales the
surviving pillars to sum to 100. The analytics route resolves the
module server-side and threads the flag; the insights card hides the
Mood row via a new SSR-safe `useModuleEnabled` accessor so it never
advertises a pillar the account turned off.
Golden fixtures pin mood-on vs mood-off to different, correctly
re-weighted scores and assert the disabled read is never issued.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polar exposes only per-stage duration totals, so its mapper already
reconstructs a contiguous CORE-DEEP-REM order exactly like WHOOP and flags
each segment reconstructed. The night reader's honesty allow-list only held
WHOOP, so a Polar-won night was served with reconstructed:false — presenting
a synthetic stage order to web and iOS as if it were the device's measured
timing. Add POLAR to the allow-list so the layout is labelled approximate.
Polar's total_interruption_duration carried the night's awake time but was
dropped, so every Polar night read as fully consolidated (awakeMinutes:null,
awakenings:0) with overstated asleep-vs-in-bed efficiency. Lay it as a
leading AWAKE segment so the asleep stages partition the remaining window and
the reader surfaces real awake time.
WHOOP and Polar independently implemented the same contiguous-timeline walk.
Extract a shared reconstructContiguousSleepTimeline helper under src/lib/sleep
and call it from both; each mapper keeps only its field normalisation. Oura's
measured 5-min hypnogram is a different algorithm and is left untouched.
The dashboard snapshot and the /api/mood/analytics route each carried a
hand-copied rollup read, coverage fallback, and daily summarize of the
same numbers. Two reads of one metric is the seam where the dashboard
tile and the insights mood sparkline can drift apart.
Extract buildMoodDailySeries as the single mood engine and route both
callers through it, so the same mood number reads identically on the
dashboard, the insights surface, and the iOS client.
WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.
Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.
The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
Add WHOOP-native DAY_STRAIN (0-21) to the Coach snapshot as a dedicated
dayStrain block, kept distinct from the computed STRAIN_SCORE (0-100) and
preferred native-over-computed, mirroring recovery resolution. Document the
glucose.clinical panel (TIR/GMI/eA1C/CV) plus the stillLearning and
isSpotEstimate gates in the system prompt, and the native dayStrain scale in
the derived paragraph. Extend the DE and FR/ES/IT/PL evidence-block unit lists
with mg/dL, mmol/L and min so a non-English coach can cite glucose and duration
values. Export GLUCOSE_PANEL_WINDOW_DAYS as the single source for the 30-day
clinical-panel window and import it across the analytics route, dashboard
snapshot, and Coach snapshot. Compute the derived metrics concurrently.
Hard-coding isSpotEstimate: true mislabels a dense Nightscout CGM stream
(~288 readings/day) as a spot estimate and undersells the data in the
clinical panel and coach. Compute readings-per-day over the covered span
and treat the series as continuous below the spot caveat once it clears a
~hourly bar (24 readings/day); sparse spot fingersticks keep the caveat.
The panel only renders the spot-reading line when isSpotEstimate, and the
OpenAPI field relaxes from a literal-true to a boolean.
Review follow-up to the shared entry-instant guard. The past-floor refine
re-ran the full plausibility predicate, so a future-dated instant tripped
both refines and the 422 carried a nonsensical "too far in the past" message
alongside the correct future one. The floor refine now checks the past bound
alone; a future value emits only the future message, matching the pre-refactor
single-message contract.
Also harden the Health-Score BP inputs and de-duplicate the tile-window
derivation surfaced in the same review:
- the prior-week graded score now keys off the prior-week window's own
sufficiency rather than the current window's, so the week-over-week graded
delta never pairs a confident current value against a thin-sample prior one;
- the BD-Zielbereich 90-day count + effective span derive through one shared
`deriveBpWindow90` helper, so the dashboard snapshot and the analytics thick
slice can never compute the gate or the label span differently.
The BD-Zielbereich tile narrated a static "· 90 T" scope even for a user
with only a few weeks of history or a handful of readings — a headline that
reads like a stable quarter-long average when it is really a thin sample.
Add a shared, reusable window-confidence helper that, given a window length
and the readings observed inside it, returns the EFFECTIVE label span (the
real calendar span capped at the window, so "· 23 T" until ~90 days of
history exist) and a sufficiency flag (false below five readings). The helper
is metric-agnostic so the glucose time-in-range / GMI and sleep tiles can
reuse it.
Wire it into the in-target headline: below the confidence floor the tile
shows "sammelt noch Daten" instead of a hard percentage, with the dynamic
span label; above it the percentage shows with the real span. The
Health-Score BP pillar reads the same sufficiency notion through the shared
input builder, so a thin-data user no longer gets a confident pillar grading
off a few readings — the all-time rate still rescues a deep-history account
with a quiet quarter. The 90-day pair count and effective span thread from
the analytics route and the dashboard snapshot through to the tile.