The medication card reads the schedule engine's next-due (fixes
interval/one-time schedules that never advanced after a dose), logging a
dose confirms itself, Insights status polling is bounded, each Insights
category explains itself inline, medication history shows each dose's
origin, and an idempotent migration adds the app_settings columns that
were only ever applied ad-hoc.
Two gaps stopped a fresh demo seed:
- medication_intake_events gained a NOT NULL updated_at column in the
v1.7.0 sync keyset work. The raw INSERTs omitted it; set it alongside
created_at on every intake row so the seed does not lean on the
column's migration-only default.
- app_settings carried four columns on the schema (default_locale plus
the telegram/ntfy/web-push global toggles) that never landed in the
migration history — applied via db push to existing environments but
never committed. A database built from migrations alone is missing
them, and the admin settings read path selects default_locale, so a
fresh deploy reports "column default_locale does not exist" on first
read. Add an idempotent ADD COLUMN IF NOT EXISTS drift-fix migration
mirroring 0025_user_locale_drift_fix: a no-op where the columns exist,
a clean add on a migrations-only build.
Verified end to end against a fresh Postgres 16 built from the full
migration set: the seed completes and re-runs cleanly.
The per-metric status cards and the inline medication-compliance query
poll the read-only status route every 4 s while the payload reports
preparing:true. A configured provider whose generation persistently
fails leaves preparing pinned true, so an open Insights page would poll
indefinitely — steady battery and network draw on a screen the user has
walked away from.
Cap the poll at 12 attempts (~48 s) through a shared
nextStatusPollInterval helper both call sites route through. Past the
ceiling the card settles on its existing static / empty state. The happy
path is unchanged: a generation that lands within a couple of intervals
clears preparing and stops the timer exactly as before, and a terminal
no-provider payload never carried preparing so it stays terminal.
The intake history surfaced the coarse IntakeSource enum directly, so a
dose logged from the iOS app showed nothing meaningful. Map the enum to
the practical client it represents — WEB to Web, API to iOS App,
REMINDER to Reminder, IMPORT to Import — and render it as a muted
"via {origin}" caption on each intake row next to the status. No schema
or API change; the source value already rides the existing intake
response. Tighten the en/de labels that read "External API" and
"Telegram / Reminder" into the same origin vocabulary across all six
locales.
The per-category explainer text only lived behind the "?" popover next to
the heading. Render the same localised definition inline as a muted caption
directly beneath the title, where the short scaffold line already sits, so
the one- to two-sentence definition is discoverable without a tap.
Both surfaces read the identical insights.subPage.explainer.<metric>Body
key, so there is no duplicated copy and all six locales stay covered. The
"?" glyph stays in place as the always-available pointer to the same text.
HealthkitMetricPage already forwards explainerMetric to the shell, so every
routed metric sub-page picks up the inline caption from this one change.
Two fixes on the medication cards.
Next-due: the card recomputed "next intake" client-side from
`daysOfWeek` + `windowStart` alone, ignoring rolling intervals, RRULE,
one-shot doses, and the last-intake anchor. A rolling medication (e.g. a
35-day interval) showed "tomorrow" and never re-anchored after a dose.
`GET /api/medications` already returns `nextDueAt` per medication,
computed by the canonical recurrence engine anchored on the last intake.
Both card variants now render that value directly: the generic card
drops its `getNextOccurrenceTimestamp` walker, and the GLP-1 card drops
its weekly-only predictor that hardcoded a 7-day step. The day-label and
window-range rendering are unchanged; only the timestamp source moves to
the server. `nextDueAt` is threaded through the list page's medication
type into both cards.
Intake confirmation: tapping Taken / Skipped now raises a toast naming
the medication ("… recorded as taken" / "… skipped"), so the action has
clear feedback. The next-intake line refreshes on the invalidated list
query, which the next-due fix above makes accurate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-category Insights assessments are served read-only from cache and
generated out of band, the client request is capped, the overnight warm
pass keys on the shown language, a stalled model is briefly remembered,
and Insights/dashboard stream a loading skeleton — so opening and
switching Insights categories stays responsive instead of freezing.
Opening an Insights category page froze the whole UI: the per-metric
status card fired a cold, uncapped LLM fetch on mount with no client
abort, so the request hung up to the server's 60s ceiling and the
pending/resolve re-renders thrashed the tab strip and ate touch events.
Timeouts were never negatively cached, so every visit re-blocked.
Fix the class holistically:
- Bound every status GET on the client. The shared use-insight-status
hook and the inline medication-compliance query now wrap fetch in an
8s AbortController and stop retrying inline; navigation can no longer
await an uncapped round-trip.
- Make the status routes read-only. On a cache miss the generator
enqueues an out-of-band pg-boss job (insight-status-generate) and
returns a preparing envelope; the client polls a bounded interval
until the worker warms the cache. Models the dashboard snapshot's
ready/preparing pattern. The queue is registered in the worker
allQueues loop + a boss.work handler, with a guard test.
- Fix the nightly warm-pass regression: warm every supported locale
(the client reads against the active UI locale, not the persisted
User.locale) and warm on a cached comprehensive outcome too (its
write still evicts the per-status caches). Add a short-TTL negative
cache on the timeout path so a transient provider stall no longer
makes every subsequent visit re-block or re-enqueue.
- Add loading.tsx for the insights segment + the root so any future
RSC await streams a skeleton instead of blocking the transition.
- Stabilise the advisor regenerate callback so a status-query flip
can't re-render the memoised tab strip mid-gesture.
New i18n key insights.assessmentPreparing across all six locales.
Guarantees a single intake entry per scheduled dose: intake writes snap
to the canonical slot instant and update in place rather than inserting a
sibling row, a re-sync can no longer clear a recorded dose, concurrent
logs converge, and a one-time startup pass collapses existing duplicates.
Review of the slot-convergence change surfaced gaps that could mis-record
a dose. Fold the write logic into one shared `applyCanonicalSlotWrite`
used by both intake routes and close them:
- Concurrency: catch the unique-key race on insert and converge by
re-finding the slot row and updating it, instead of returning 500 (and
dropping the tap) or leaving a duplicate.
- No downgrade: a pending projection echo replayed onto a slot already
marked taken or skipped is a no-op — it never clears `takenAt`. A
recorded dose is immutable, so an offline re-sync cannot flip a taken
dose back to missed.
- Inventory: decrement a medication's remaining count only on a genuine
pending→taken transition, not on an idempotent re-post.
- Deterministic slot-row selection: prefer an existing actioned row, then
the pending row, ordered by createdAt then id.
- Snap instant: derive the canonical instant via the same `localHmAsUtc`
the projector and reminder worker mint with, so the value is identical
by construction across a DST transition rather than only test-pinned.
- Cleanup job: isolate each slot's collapse in its own try/catch and
guard the instant-normalisation against a tombstone collision, so one
slot can no longer dead-letter a user's whole dedup pass.
The write-path convergence stops new duplicate slot rows, but accounts
that already accrued a pending + taken pair for one dose keep an inflated
expected count, a phantom taken marker, and a suppressed "due now" prompt
until the bad rows are cleared.
Add a boot-time, idempotent cleanup queue (mirroring the existing
backfill queues): discover only the users holding a same-slot duplicate,
then per medication snap every live intake to its canonical slot, keep
the most-actioned row per slot (taken over skipped over pending),
normalise its instant, soft-delete the losers, and recompute the
affected compliance rollups. Rows that resolve to no scheduled slot
(PRN / as-needed) are never collapsed. A second pass is a no-op.
Registered in the worker's queue list with a static guard test, after a
prior incident where a queue was built but never registered and silently
never ran.
A twice-daily medication ended up with two rows for one dose slot: the
projector / reminder worker mints a pending row (source REMINDER) at the
slot's `localHmAsUtc` instant, while a "taken" write from the per-med or
bulk intake route inserted a fresh row (source WEB / API). Because
`source` is part of the `(userId, medicationId, scheduledFor, source)`
unique key — and the written instant could drift by a minute from the
projected one — the two rows coexisted: one taken, one still pending and
overdue. The card then read the slot as taken, counted 100%, and
suppressed the "due now" prompt for a dose the user had not taken.
Resolve every scheduled intake write to the slot's canonical instant
before writing and upsert in place, source-agnostic:
- `resolve-slot-instant.ts` snaps an incoming instant to the nearest
scheduled slot for that day (enumerated through the canonical
recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and
anchors it on the same `localHmAsUtc` the projector and worker use.
Off-slot and as-needed writes resolve to null and keep insert semantics.
- The per-med and bulk routes look up the existing row at the canonical
`(userId, medicationId, scheduledFor)` ignoring source and update it in
place — mirroring the status-toggle route — instead of inserting a
sibling. The bulk per-entry response gains an additive `updated` status.
- The reminder worker's missed-dose mint now skips a slot that already
carries an actioned row (taken or skipped) from any source, not only an
existing pending REMINDER row.
PRN / as-needed writes are unaffected. Regression tests pin the collapse
(exact and one-minute drift), the PRN exemption, and the worker guard.
Closes the two surfaces the 1.7.3 multi-time compliance fix did not
reach: the today-tile projection now mints one pending dose per
scheduled time, and the compliance heatmap classifies each dose against
its nearest scheduled time instead of the first window.
The History compliance heatmap matched each taken intake event to the
closest schedule by `windowStart` alone, ignoring `timesOfDay`. For a
twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` and `windowStart = "07:00"`, the evening
dose matched the lone 07:00 window and classified as very-late (~12h),
painting the day's cell orange even when both doses were taken on time —
so the heatmap never recovered to green for single-row multi-time
schedules.
Expand every schedule into its effective dose slots (its `timesOfDay`,
else `[windowStart]`) and classify each event against the nearest slot's
window — the slot time anchoring a window that spans the schedule's
`windowEnd − windowStart` duration, overnight spans included. Single-time
schedules collapse to one slot at `windowStart` and stay byte-identical.
The denominator already routed through the canonical engine; only the
numerator's timing classification is corrected.
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.
A medication whose schedule row carried multiple timesOfDay plus a legacy
daysOfWeek (and no rrule / rollingIntervalDays) reported 50% compliance for a
fully-adherent 2x/day dose. The expected-slot denominator always ran the
canonical recurrence engine and counted both daily slots, but the numerator
only delegated to the engine for rrule / rolling / one-shot / non-SCHEDULED
rows; a plain-legacy row fell to the local cadence walker, which emitted a
single windowStart slot per day and ignored timesOfDay. 1 taken / 2 expected
read as 50 percent.
Route expandScheduleSlots through the engine for every schedule shape whenever
a medication context is supplied, so the numerator and denominator read the
same source of truth. Harden the context-less legacy walker to iterate
timesOfDay as well, mirroring the engine's legacy branch, so the single
remaining non-engine path stays correct for multi-time rows. Drop the now-dead
expander gate helper.
Add regression guards: a 2x/day legacy row reports 2/2 through
calculateCompliance, and expectedSlotCountForDay agrees with
buildCadenceTimeline on the slot count for an identical schedule.
Four layout defects in the clinical PDF renderer:
- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
outside it (the trend arrows the summary injects, the superscript-two in
kg/m2) resolved to the .notdef box at a fallback advance width and stretched
the surrounding words. Route every drawn string (direct text, wrapped
paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
arrows render as true glyphs is a documented follow-up.
- The sparkline dropped each reading's timestamp and plotted on an index
axis, so a trend gave no sense of its time span. Plot points by their real
timestamp and print the first/last dates under the baseline.
- No consistent bottom safe area: scattered y > 240 magic numbers and tables
with no bottom margin let content run into the footer. Introduce a single
contentMaxY derived from the page height, footer band, and bottom margin;
every break check and every autoTable bottom margin refers to it.
- Modules tore mid-block. Add an ensureSpace guard called before each module
heading and a per-line guard in every free-text loop; give the short
titration and side-effect tables rowPageBreak: avoid.
The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.
Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
The warm pass forced the seven per-metric status generators strictly
back to back per user, so a fully configured account paid ~7× one LLM
round-trip serially. Bound the inner pass with a small `p-limit(3)` so
the cards warm a few at a time without saturating the Prisma pool or
fanning out an unbounded number of provider calls. The per-user budget
gate still bounds total cost per night.
The targeted status-insight invalidator only fired on insert and batch
ingest, so editing or deleting a reading left the affected per-metric
assessment cards serving their pre-mutation text for the rest of the day.
Add the fire-and-forget invalidation to the single-measurement PUT
(covering both the old and the new type when an edit re-types a row),
the single-measurement DELETE, and the iOS by-external-ids
reconciliation DELETE (keyed off the distinct types it tombstoned).
Read the MONTH / YEAR rollup tier for the monthly / yearly slices of the
focused single-metric status snapshots (weight, pulse, bmi,
blood-pressure) via `buildGradedSeriesWithRollups`, so the coarse tiers
finally earn their write amplification. BMI has no tier of its own, so it
scales the WEIGHT tier by 1 / heightCm² (`scaleGradedSeries`) — an exact
linear transform. The multi-metric general overview and the tier-less
mood / adherence series keep their bounded in-memory fold.
Fix the coverage-miss path that returned an always-empty monthly / yearly
slice: the bounded ~90-day recent read can never supply those slices, so
on a cold-tier miss the builder now folds the full history in memory
rather than shipping empty coarse arrays when years of raw history exist.
Excludes tombstoned rows from both reads.
Lift the byte-identical `round`, `normalizeSummaryText`,
`normalizeLocale`, `summarizeSeries`, the `{summary}`-envelope parse, and
the cache-row persist block out of the seven generators into
`status-shared.ts`. Drop the per-medication expectation in the compliance
generator: the prompt returns only `{summary}`, so the per-med cards are
server-side placeholders rather than a dead model-authored branch. Remove
the unused `summarizeGradedSeries` export.
Behaviour is unchanged on the in-memory path; the rollup path is exact
where the tier composes (count / min / max / mean) and the payload stays
under the snapshot char cap.
Two small polish items on the insights category surface.
The desktop explainer popover body rendered at 11px while the mobile
sheet used the larger `text-sm`, an uncomfortable and asymmetric read.
Lift the popover body to `text-xs` (12px) so both surfaces sit on the
type scale.
The `MetricTargetSummary` docstring claimed both the range and the
in-target share are hidden when the route flags `insufficientData`. The
range stays visible — only the share is suppressed. Correct the comment
to match the code.
`<MetricExplainer>` resolves its copy through a template literal
(`insights.subPage.explainer.${metric}{Title,Body}`), so the static
call-site coverage guard never sees these keys — a typo in an
`explainerMetric` prop would paint the raw key path with no CI signal.
Walk every `explainerMetric="…"` value under `src/app/insights/**` plus
the shared healthkit-metric-page scaffold and assert each resolves a
Title and Body leaf in messages/en.json. Also pin the literal-only prop
contract and a non-empty match count so a future rename can't silently
disable the guard. The locale-integrity test then carries the EN
guarantee to the other five bundles.
Two parts to the explainer surface, landed together because they share
the same i18n block and page props.
Coverage: every routed insights category page now passes an
`explainerMetric` prop, so the heading-side `?` glyph is present on all
37 pages instead of 13. The 24 newly covered metrics — body
composition (body water, bone/fat/muscle/lean mass, fat-free mass,
visceral fat), mobility (flights, walking distance/speed/steadiness,
asymmetry, double-support, step length, walking heart rate),
cardiovascular (pulse-wave velocity, vascular age), hearing
(environmental + headphone levels, loud-sound events), respiratory
rate, daylight, blood glucose, and skin temperature — each gain a one
to two sentence layperson definition grounded in standard clinical
references, shipped in all six locales.
Keys: the explainer key segments were a German/English mix
(`blutdruck`, `puls`, `gewicht`, `schlaf`, `stimmung`, `medikamente`
alongside English ones). Rename them to English to match the
naming-convention ADR — `bloodPressure`, `pulse`, `weight`, `sleep`,
`mood`, `medications` — across every locale, and update the
`explainerMetric` prop values that reference them. These are i18n key
segments, not route slugs, so the slug contract is untouched.
The hard '~5 measurement points' floor did not fit mood (counted in
entries) or medication adherence (counted in scheduled doses). Re-word
the gate as 'a few measurement points/entries/events' and note that the
unit depends on the metric. Also fix the gating-field path: the recency
field lives at dataCoverage.newestMeasurementDaysAgo, not on summary.
The mood card noted persistent low phases but offered no gentle path to
support. When mood stays clearly low for several days, the model now
offers an autonomy-preserving pointer toward talking it through with a
trusted person or a professional — never alarming, never a diagnosis, no
emergency tone. The safety-contract matrix does not cover this pathway
and does not reach the per-metric assessment prompts, so it lives here.
For an infrequent schedule such as a weekly injection, the 7-day
compliance figure is dominated by a single dose: one miss swings the
percentage sharply and reads as a collapse it is not. Tell the model to
lean on compliance30 as the load-bearing value when schedulesPerDay is
low and treat compliance7 as a rough pointer only. Output stays {summary}.
The overall-status card summarises up to six metrics, but it inherited
the single-metric 30-60 word budget, which forced the model to either go
generic or overrun. Grant the section an explicit 3-5 sentence / 50-80
word budget and cap it at naming two metrics by name, with the rest
mentioned only when they deviate notably from their own mean.
The bmi and weight cards produced near-identical trend text for a user
with both metrics, since bmi is weight scaled by a constant. Re-aim each
card so they carry distinct information: bmi now centres on WHO-band
placement (call out a band crossing or a near-boundary value against the
person's own baseline), and weight stays on the continuous trend and
pace (kg/week, plateau, milestone). Each defers the other concern to its
sibling card.
Regenerate `docs/api/openapi.yaml` so the `InsightsLayoutBody` enum
carries the canonical English tile ids first, then the
accepted-but-deprecated legacy German ids, with the normalisation +
deprecation spelled out in the schema description. Add ADR 0001 fixing
the convention — localised UI copy, English internal identifiers — and
the non-breaking alias strategy. Add an iOS coordination note covering
the new canonical ids, the continued acceptance of the legacy ids, and
the recommended migration path.
The tile-layout enum migrates from German to English to match the route
slugs. The contract on `GET/PUT /api/insights/layout` stays
non-breaking for clients that persisted the old German ids (the iOS
layout sync among them):
- the Zod enum is built from `ACCEPTED_INSIGHTS_TILE_IDS` (canonical
English plus the legacy German aliases), so a PUT carrying old ids
validates rather than tripping a 422;
- `normalizeInsightsTileId` collapses each legacy id onto its canonical
English replacement on both the read path (`resolveInsightsLayout`)
and the write path (`serializeInsightsLayout`), so a pre-rename layout
surfaces English ids on GET without a forced re-PUT and a legacy PUT
persists English ids;
- a layout carrying both a legacy and a canonical id for one tile
dedupes to a single canonical entry.
The legacy ids are accepted-but-deprecated. Tests cover the read-side
normalisation and the write-side 200 (no 422) + canonical persistence.
Rename every routed Insights sub-page slug from German to English
(`/insights/blutdruck` -> `/insights/blood-pressure`, `gewicht` ->
`weight`, `medikamente` -> `medications`, and the full v1.7.0 metric
tail). UI copy stays localised through `t()` keys; only the internal
slugs change. The slug registry in `src/lib/insights/sub-page-metric.ts`
is the single source of truth, so the tab strip, target summary, and
daily-briefing routing targets follow it.
`next.config.ts` `redirects()` emits a permanent redirect from every
legacy German slug to its English target, including a `:path*` variant
so nested routes survive. Bookmarks and cached PWA navigation keep
resolving.
Drop the dead-field guidance (avg90/allTime, historicalComparison,
ratePressureProduct, bodyCompositionDivergence, seasonalVariation) and
the chart-token instruction. Describe the per-type measurementSeries map
and the adherence/mood/target context, pull them into one coherent
picture against the person's own baseline, and close with the single
most important doable step.
Replace the historicalComparison and moodAdherenceRisk guidance with the
actual per-medication snapshot fields (compliance7/30, streak,
taken/skipped/missed, dailySeries). Read the recent direction as
compliance7 vs compliance30 against the person's own baseline, frame
positively, and close with one blame-free doable step.
Describe the graded mood series, target band and tag list, lead on the
weekly pattern against the person's own baseline, name persistent low or
high phases calmly as an observation rather than a diagnosis, and close
with one kind doable step.
Describe the graded BMI series and target band, lead on the trend over
the single value, keep the WHO bands as placement anchors rather than a
label for the person, and name BMI's body-composition limits plainly.
Drop the chart-token instruction the status path strips out.
Replace the dead-field guidance (historicalComparison.weight,
allTimeMax, slope30, bodyCompositionDivergence, sleep.avg7) with the
graded weight series. Lead on the weekly/monthly trend rather than the
single value, define plateau and sustainable pace against the person's
own baseline, anchor milestones to their own maximum, and gate BMI
context on a known profile height.
Drop the stale snapshot vocabulary (avg7/30/90, historicalComparison,
slope30, rate-pressure product) and the chart-token instruction the
status path strips out. Describe the graded pulse series and target
band, compare the recent days to the person's own weekly/monthly
baseline at a ~5 bpm significance threshold, and frame a rising or
falling resting pulse as a possible explanation rather than a diagnosis.
The status cards render only the model's summary field, yet the prompt
described a snapshot vocabulary (avg7/30/90, historicalComparison,
ratePressureProduct, seasonalVariation) that the graded snapshot no
longer carries, and demanded a full findings/correlations JSON schema
that this path never reads.
Rebuild the shared base prompt around the actual graded shape (summary +
recent/weekly/monthly/yearly with min/max/mean/slope), the own-baseline
comparison, a name -> place -> one-step structure, a warm
autonomy-supporting tone, a 2-4 sentence length, honest data-gating, and
a { summary } output. The blood-pressure section judges systolic and
diastolic together against the person's own baseline with ESH 2023 as
the placement anchor.
Hang `invalidateStatusInsightsForTypes` off every measurement ingest
path so a new reading of a type drops the cached assessment rows that
reading dirties — the next mount or nightly warm pass then regenerates
them against the new data instead of serving the pre-measurement text
for the rest of the day.
Wired into both `/api/measurements` modes (single + multi-entry batch),
`/api/measurements/batch`, and the three Withings sync paths
(measures + activity + sleep), reusing the `collapseToTypeDayKeys`
result each already computes for its rollup recompute. The call is
fire-and-forget after the user's write commits: idempotent, never a
blocker on the request path, and a failure only warns.
The comprehensive generator evicts the seven `insights.<scope>-status`
caches as part of its write so a stale comprehensive insight can never
leave a stale per-metric card behind — but it does not re-fill them.
Before this, the morning after the nightly pre-gen cron ran the
per-metric status caches were empty, so the first `/insights` mount
still ran one live status generation per card.
After a successful comprehensive generation the cron now forces the
seven `*StatusForUser` generators so their caches are warm, not empty,
by the user's first visit. The warm pass only runs on a `generated`
outcome (a no-provider `skipped` has nothing to warm; a `failed` run
means the chain is unhealthy this tick — re-running it seven more times
would only multiply the failure). Each generator resolves the provider
chain internally, so a provider-less account stays cheap. The per-user
budget bucket that already bounds the comprehensive pass also gates the
warm pass — both run behind the same once-per-20h gate.
`runInsightPregenerate` now reports `assessmentsWarmed` and emits an
`insights.pregenerate.run` wide-event with the full per-run tally so the
nightly dashboard can track how many assessments each tick wrote.
Also adds `invalidateStatusInsightsForTypes` + `PER_STATUS_SCOPES` to
the comprehensive generator: a type → scope map (WEIGHT → weight + bmi +
general, BP → blood-pressure + general, PULSE/resting-HR → pulse +
general, everything else → general) so the ingest path can drop only the
scopes a fresh reading dirties.
Pin the snapshot payload budget: a 2-year daily weigher (weight + daily
BP) and a six-type dense account both serialise under the 24 000-char
cap, proving the graded rewrite holds the line on the worst-case
accounts. The stub-skip and timeout-no-persist behaviour is covered by
the per-generator cache-read tests + the shared status-cache suite.
The seven per-card generators embedded the full ~360-day daily array
per metric plus correlation pair arrays, and never touched the
WEEK/MONTH/YEAR rollup tier. A dense daily weigher ran the weight
snapshot ~80-110 KB; a many-type account could push general-status past
100 K tokens.
Embed the shared graded series instead of the full daily array, cap the
embedded correlation/paired arrays to their most recent entries, skip
measurement types with no data in general-status, and apply a generic
24 000-char snapshot budget that sheds yearly -> weekly -> array-tails
-> recent until it fits. The applyPayloadBudget daily buckets stay
in-process to drive the latest-day focus, in-target gates, and
correlation pairing; only the compact shape reaches the prompt.
Measured on a 2-year daily account: weight-status ~15 KB, general-status
~12 KB across six dense types — both well under the cap.
Turn a metric's history into a small, bucketed, AI-ready shape: the
last ~21 days verbatim at daily granularity, the ~10 weeks before that
as ISO-week means, the ~12 months before that as calendar-month means,
and everything older as yearly buckets with a least-squares trend
slope. ~30-45 rows per metric regardless of history depth, never an
individual reading beyond the recent window.
A pure in-memory builder folds raw points; a rollup-backed builder
sources the monthly/yearly slices from the pre-aggregated MONTH/YEAR
tier (readBestGranularityRollups) and the recent/weekly slices from a
bounded ~90-day raw read, so the coarse rollup tier finally earns its
write amplification. The recent/weekly bucketing mirrors the coach's
timeline split, duplicated here so the coach contract stays put.
The per-card status path capped provider generation at 20 s and, on
timeout, persisted the generic no-key fallback as a day-keyed cache row.
The cache-read only checked the day key and text, so that stub looked
like a real assessment and stuck until midnight — hiding the real,
data-driven text for the rest of the day on any account that hit a
single transient stall.
Align the budget with the provider clients' 60 s ceiling, route the
status path through resolveProviderChain + runRawCompletionWithFallback
so a degraded provider cascades like the coach, and stop persisting the
fallback: a timeout or error is a transient miss that serves the
fallback for the current render and re-attempts on the next mount. A
shared cache-read rejects any lingering timeout-stub rows by their
markers, and withTimeout now annotates real errors instead of folding
them silently into the timeout path.
Each insights category page now carries two additions placed beside the
chart and assessment:
- a target-range card (MetricTargetSummary) showing the numeric goal
band and the share of logged days within it, read from the shared
/api/insights/targets payload so the bands match the /targets editor
exactly. Blood pressure stitches the systolic and diastolic bands into
one S/D string. A discreet link routes to /targets to adjust the band.
- a question-mark explainer next to each heading (MetricExplainer in
SubPageShell) opening a static, layperson 'What is X?' read. Popover on
desktop, bottom-sheet on phones; keyboard- and touch-reachable with a
44px hit surface. Copy ships in all six locales.
The /targets page stays the canonical place to tune the bands.
The advanced sheet sits on one medication and its import already loads
that medication's intake history, but the co-located export streamed
every medication. Pass the medication id through to the export route,
which narrows both the medication row and the intake history by it
(always alongside userId, so a foreign id 404s rather than leaking).