Remove the unused output-key reverse map the batch path never read, and
have the budget table satisfy the AiBudget interface directly so the type
is enforced rather than orphaned.
Split each per-metric status generator at the provider boundary so the
snapshot build, cache read, and content-hash gate run without the model,
then send one structured request covering every metric that has data and
fan each summary back into the same per-metric cache row. Collapses the
seven specialised status calls per warm cycle into one while leaving the
read path, caching, and single-card behaviour unchanged.
Prove one provider call covers every present metric, absent metrics are
omitted from the prompt and never fabricated, an all-served cycle makes no
call (the per-metric hash gate still short-circuits), and the batch degrades
gracefully: an omitted metric or a provider error falls each affected card
back to its single-card path, invalid JSON retries once, and a no-provider
cycle serves each card's precomputed no-key result.
Add generateStatusBatchForUser: it prepares the seven specialised status
snapshots once, sends one prompt for a { perMetric } envelope covering only
the metrics with data, and fans each summary back into the same per-metric
cache rows the standalone generators write. One JSON-correction retry guards
the parse; any omitted metric or a failed batch degrades to that card's own
single-call path, so a partial response never goes dark. The nightly warm and
the forced warm route through the batch in production while the injected
per-card seam keeps the cron tests intact. Adds the status-batch prompt
scaffold composed from the shared assessment contract.
Restore the clinician share-link section as its own Settings entry next to
the health-record export, rendering through the shared SettingsCard and the
envelope-aware api helpers. The backing routes and the public view were
untouched by the earlier trim, so the owner UI reconnects to them as before.
Bring back the Settings → Sharing ("Freigabe") section: the owner mints
a time-boxed, revocable, read-only share link to their health record at
the public /c/[token] view. The backing model, the /api/share-links
lifecycle routes, and the public clinician view were left in place; this
restores the owner Settings surface and re-wires it.
Place the slug directly after gesundheitsakte and before export — sharing
the health record belongs next to the health-record export. The entry is
always available (no module gate), like account and export.
Render the card through the shared SettingsCard primitive so it matches
every sibling section; drop the section's own page title (the shell
supplies the heading and subtitle). Route the list, create, and revoke
calls through the api-fetch helper instead of raw fetch, and align the
touch targets with the mobile sizing contract.
Apply the prepare -> run -> finalize split to blood-pressure, pulse, bmi,
mood, general, and medication-compliance status, matching weight-status.
Each public generator keeps its signature and one-call behaviour by driving
its prepared card through the shared single-card path; the snapshot build,
cache read, read-only miss, and content-hash gate are now reusable by a
batched caller without changing what each card persists.
Introduce a prepare -> run -> finalize contract for the per-metric status
cards so the snapshot build and the provider call can be driven separately.
The public generator keeps its signature and one-call-per-metric behaviour;
weight-status now resolves through the shared single-card path. Add the
status-batch AI budget entry sized for one combined seven-metric call.
Replace the hand-rolled Settings card divs across every section with a
shared SettingsCard wrapping the Card primitive, eliminating the sm-vs-md
padding split and the primitive bypass, and raise the Settings touch
controls to a 44px floor that condenses at sm+.
Apply the canonical min-h-11 sm:min-h-9 (and min-w-11 for icon-only
controls) to the primary Settings controls that sat at 32-40 px on
phones: the AI fallback-chain reorder/remove/save/add icons, provider
form save/test/connect actions, coach-memory forget, avatar replace and
remove, section save/cancel/reset CTAs, the export and import action
rows, the About external links, and the thresholds editor row. Collapse
the injection-sites checkbox grid to one column on phones and stack the
password-change grid below lg.
Two parallel changes both claimed 0178; the session-expiry indexes keep
0178 and the briefing-reroll column moves to 0179 so the sequence stays
unique and ordered.
Centralise per-surface AI budgets and right-size status maxTokens; add
seed support (pinned for reference surfaces, seedless for the briefing);
force JSON on Ollama and Anthropic; reroll only the daily-briefing prose
once a day for variety while findings stay stable; reuse the corrective
retry on the comprehensive path; extract shared prompt contracts as one
source of truth for grounding, tone, and the GLP-1 safety rule; reserve
the coach budget atomically before the call and reconcile burned tokens;
turn the local-AI private-host flag into a host allowlist.
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` was a binary flag that, when `true`,
opened every private/internal host at once — including the cloud
metadata endpoint and any internal panel. Replace the binary check at
all three sites (write-time validation, local client, test resolver)
with a shared `isLocalAiHostAllowed` helper: a comma-separated host list
permits only those exact hostnames, while the legacy `true` still
permits any private host for backward compatibility. The safeFetch /
isPublicUrl floor is untouched; the allowlist only widens what the
operator deliberately permits. Docs and the SSRF-pin inventory test are
updated accordingly.
Raise primary touch controls across the non-Settings surfaces to a 44px
floor that condenses at sm+, add a shared ListRow primitive and migrate
the three bespoke list-row mini-cards onto it, and add a scroll-touch
utility for the horizontal scrollers.
Assert the send and stop controls render the `size-11 sm:size-9` tap-
target pattern so a future restyle cannot quietly drop them back to the
36px size the audit flagged. Renders the real component and matches the
emitted class string, the same model as the bottom-nav size test.
Sweep the primary touch controls that sat below the 44px tap-target
floor on phones onto the canonical `min-h-11 sm:min-h-9` (text) /
`size-11 sm:size-9` (icon) pattern, condensing back at `sm:` upward:
- Coach composer mic / stop / send icon buttons (were `size-9`).
- Dashboard hero CTA, link and action variants (were `size="sm"`).
- Vorsorge list-view "mark done" button (was `min-h-9`) and its kebab,
which now uses `size="icon-lg"` so the rendered control is 44px
rather than the icon variant's 40px.
- Illness list-view "log day" button (was `min-h-9`).
- Medication wizard Step 3 injection-sites disclosure (was `h-9`).
The Vorsorge dashboard-card capture action was raised alongside the
ListRow migration.
The bordered list-row "mini-card" appeared three times with three
shapes: the Vorsorge dashboard row (`rounded-md p-2.5`), the daily-
briefing rows (`rounded-md p-3`), and the measurement-list mobile row
(bespoke `rounded-lg p-3` div). Introduce `ListRow` (`rounded-lg border
p-3`, `asChild` per the shadcn convention) and migrate all three to it.
The primitive owns only the box shape; each caller keeps its layout,
colour, and interaction classes, so this is structural normalization
with no content or behaviour change.
Add a `.scroll-touch` utility that restores iOS WebKit momentum
scrolling (`-webkit-overflow-scrolling: touch`) and contains the
overscroll chain so a flick at the end of a horizontal row does not
rubber-band the whole page. Apply it to the insights tab strip and the
cycle tab list — the two in-app horizontal scrollers that lost inertial
flick under the default `overflow-x-auto`.
Migrate the ImportCardShell and ExportCardShell containers to
SettingsCard, threading their dynamic outerClassName through cn. The
layout hub-link row keeps its element (a full-row Link, not a content
card).
The daily token cap was read before the provider call and recorded only
after a successful reply, so concurrent requests could all read
`spent < cap` and all hit the provider, and an empty/sentinel reply
recorded zero spend despite burning upstream tokens.
Reserve the budget atomically before the call: a single upsert-increment
returns the new total, so concurrent requests serialise on the row and
cannot both pass the cap; over-cap refunds the reservation and surfaces
`coach.budget.exceeded` as an SSE error frame. Reconcile the reservation
to the actual token count immediately after the provider returns —
before any empty/sentinel short-circuit — so burned tokens are always
recorded. An integration test proves two concurrent requests cannot both
exceed the cap and that an empty reply still records its cost.
Migrate the remaining hand-rolled card containers in the account,
about, medications, api, mood, notification-status, and advanced
sections to SettingsCard, preserving each card's heading, body, and
test hooks. Leaves the passkey list-row (rounded-lg) for the list-row
token track.
Swap the hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6
container for SettingsCard across the single-card sections (integration
cards, notification channel cards, coach prefs, cycle, modules, mood
reminder, thresholds, dashboard-layout, about-me, low-stock, and the
health-record export panel). Drops the sm: padding split and the
primitive bypass; landmark sections keep their element via as="section".
Extend the demo tenant with body composition, blood glucose with meal
contexts, cardio vitals, recovery and sleep scores, and realistic workouts
with heart-rate samples, all reconciled against the existing series and
dated relative to now. Replace the stale achievement ids the prior seed
referenced with verified registry keys.
The per-worker in-process "last working provider" hint stays volatile
and is deliberately NOT folded into the durable Postgres health ledger.
The ledger already shares the load-bearing skip signal across workers;
the only uncoordinated piece is a soft reorder whose worst case is one
redundant first-hop per worker per hour. Promoting it to a per-request
ledger write would add hot-path write amplification for a negligible
saving. Documented inline so the trade-off is explicit.
The grounding, tone/banned-opener, GLP-1 dose-safety, metric-identifier
ban and forbidden-filler rules were independently encoded across the
comprehensive, status, Coach and narrative prompts and had drifted. Move
the canonical wording into `prompts/shared-contracts.ts` (locale-keyed
fragments) and compose each surface from it.
Where the surfaces had drifted, adopt the stricter wording: most notably
the GLP-1 dose-prescription contract — previously present only in the
comprehensive and Coach prompts — now reaches the status cards and the
period narrative too. A cross-surface coverage test asserts every
enforced fragment appears verbatim in every surface, in de and en.
Extend the demo tenant so it reads as a long-term, fully engaged user
across the whole app, not a thin sample. All series span the existing
ninety-day window with the latest point on today and sit in healthy
bands:
- Body composition (fat, fat-free, lean, muscle mass, total body water,
bone mass, visceral fat, BMI) derived from the same-day weight and
body-fat series so the numbers reconcile with the headline tiles.
- Blood glucose in a healthy non-diabetic band — fasting, post-meal and
bedtime readings stored in mg/dL with the glucose context tagged.
- Cardio fitness and vitals: HRV (SDNN and RMSSD), oxygen saturation,
respiratory rate, periodic VO2 max, active energy, walking/running
distance and flights climbed.
- WHOOP-style recovery, day strain and sleep performance/efficiency/
consistency scores aligned with the seeded sleep nights.
- Three to four workouts a week (running, strength, cycling) with
duration, energy, average and max heart rate, distance, a sparse
per-workout heart-rate sample series, and per-workout strain in the
metadata blob.
Clear the workout tables in the same truncate pass so the seed stays
re-runnable.
The demo seed unlocked twelve achievement ids that no longer exist in
the registry (first-measurement, week-streak-measurements,
three-month-streak, …), so the achievements surface rendered them as
unknown placeholders. Map the unlocked set to live registry keys —
first-entry badges, logging and in-range streaks, compliance and
miss-free streaks, plus the self-context and seven-night sleep badges —
each on a plausible unlock date the seeded history supports.
Wrap the ui Card so every Settings section composes from one bordered
container with the shared p-4 md:p-6 padding contract, replacing the
hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6 div that
bypassed the primitive and stepped padding at sm: instead of md:.
The medication card and table render dose-derived stock from the cached
medications list, but the inventory write routes evicted no server cache
and the client invalidated only the per-item inventory key, so a new
container's stock kept serving the pre-write value for the stale window.
Hard-evict the user's medication caches on every supply write and
invalidate the list key alongside the inventory key on the client.
The /medications card and table render the dose-derived stock
(stockUnitsRemaining / stockDosesRemaining) straight from the
GET /api/medications list payload, which reads through cachedSwr on the
60s-fresh + 10-min-stale medications bucket. Registering, adjusting, or
deleting an inventory container changes that stock, but the inventory
routes never invalidated any cache — so the list served the pre-write
stock for the rest of the stale window. On the client the add / adjust /
delete supply mutations only invalidated the per-medication inventory
query, never the medications-list query that the card reads, so a mounted
list never refetched even once the server caught up.
Hard-evict the per-user medications + compliance buckets from every
supply-affecting write (inventory POST, the per-item PATCH / DELETE, and
the deprecated glp1 POST dose-change + ledger branches) so the next read
reflects the change rather than a stale SWR value. On the client,
centralise the supply mutations on a shared invalidateSupplyQueries helper
that drops both the medications-list key and the per-medication inventory
key. The card's neutral styling is unchanged.
Fold the three standalone module settings pages into the Settings shell so
they open in-context with the same left-nav, heading, and chrome as every
other section. Remove the standalone pages and the now-unused
ModuleSettingsFrame, and drop the orphaned settings i18n blocks.
The comprehensive generator did an ad-hoc JSON.parse with no corrective
retry and failed cold to `invalid-json` on a first-pass miss, while the
strict insight wrapper's `buildRetryCorrectionMessage` sat right next
door. Export that helper and reuse it: on a parse miss, append the
correction to the user prompt and re-run the chain once before declaring
failure. Unifies the JSON-robustness story across the two paths.
Promote the three per-module customise surfaces from standalone
ModuleSettingsFrame pages to first-class Settings sections. They now
render inside the Settings shell — same left nav, heading, and chrome as
account / ai / integrations / notifications — instead of their own
back-button-and-title frame outside it.
Add labs, illness, and vorsorge to the slug registry (after mood) and to
SETTINGS_SECTIONS with FlaskConical / Thermometer / CalendarCheck icons.
labs and illness gate on their toggleable modules; vorsorge has no
module, so its entry is always shown. Three thin section components wrap
the existing LabsSettings / IllnessSettings / VorsorgeSettings content,
mirroring MedicationsSection / CoachSection.
Delete the three standalone /settings/{labs,vorsorge,illness} routes so
the wrench deep links resolve through the dynamic [section] route, and
drop ModuleSettingsFrame now that nothing renders it. Add the section
title / subtitle / description keys across all six locales and remove the
dead per-module *.settings.* blocks the old pages consumed.
The content-hash gate re-stamps the prior text whenever the snapshot is
unchanged, so the "daily" briefing read byte-stable day-over-day. Decouple
phrasing freshness from data freshness for the dailyBriefing.paragraph
alone: on a hash-unchanged hit, re-roll just the paragraph once per
calendar day at a higher, seedless temperature, keeping every structured
finding verbatim from the cached payload.
A new `insightsBriefingRerollDate` column gates it to at most one extra
call per day, and a re-roll miss still stamps the day so a failing
provider cannot be re-driven on every page-open. The hash gate stays
fully intact for the status cards (reference assessments — monotony is
fine).
Recent in-band BP, stable healthy weight, a resting-heart-rate series, a
full week of per-stage sleep nights, on-track medication doses, two
preventive-care reminders, a healthy lab panel, one resolved illness
episode, and a sample Coach conversation — all dated relative to now so
the public demo reads current for screenshots.
The measurement-reminder push already carries the reminder id in its
metadata, but the iOS allowlist stripped it before it reached the device.
Forward the key so a foreground or handled notification can post the
completion directly instead of routing through the capture deep link.
Tune the public demo tenant for App-Store screenshots:
- Extend the measurement loop to include today so every headline metric
has a current reading.
- Retarget blood pressure into the optimal band (~118/76), weight to a
stable healthy BMI, and add a clean resting-heart-rate series so the
resting-pulse tile scores it directly instead of the spot pulse.
- Write a full week of per-stage sleep nights (awake/REM/core/deep,
~7.5h, source APPLE_HEALTH) including last night, in minutes, and skip
the aggregate row for those nights to avoid double-counting.
- Schedule today's doses on-track: morning meds taken, the evening dose
pending and never auto-missed, so today never reads overdue or 0%.
- Add Vorsorge reminders (dental check-up, annual physical), one healthy
lab panel, one resolved illness episode, and an AI-configured Coach
with a short encrypted sample conversation.
All dates stay relative to now so the demo refreshes on every re-seed.
Add an opt-in `responseFormat: "json"` to CompletionParams that the
structured surfaces (comprehensive briefing + per-metric status cards)
set. On local (Ollama / OpenAI-compatible) it adds `format: "json"`; on
Anthropic it prefills the assistant turn with `{` and re-prepends the
brace to the reply, so the first emitted token is already inside a JSON
object. Both cut the first-pass JSON-failure rate that fence-stripping
otherwise has to absorb.
OpenAI already pins `response_format: json_object` and is unchanged. The
period narrative (plain text) and the Coach (streaming prose) leave the
flag unset, so their non-JSON paths are untouched.
Add an optional `seed` to CompletionParams and thread it onto the
OpenAI and local (Ollama / OpenAI-compatible) request bodies. The field
is dropped when unset, so existing callers are byte-identical.
Pin a stable seed for the status/reference cards (defaulted in the
status provider) and the period narrative, so a prompt change is
diff-able against a fixed baseline rather than masked by low-temperature
non-determinism. Anthropic's Messages API has no seed knob, so its
client ignores the field (documented inline). The seed is annotated on
the narrative wide-event.
Fix the target-edit-sheet invalidation that used a bare ["insights"] array
not matching the factory prefix the reads nest under (a silent no-op), and
migrate 12 further bare queryKey call sites onto the centralised factory.
Replace the scattered `?? 0.3` / `?? 1000` temperature and maxTokens
magic numbers across the status provider, Coach route, comprehensive
generator and period-narrative generator with a single typed
`ai-budgets.ts` map. Each entry documents the output contract that
fixes its token ceiling.
Right-size the per-metric status output from 1000 to 250 tokens: the
card renders one `{ "summary": "..." }` of 2-4 sentences (30-60 words),
so 1000 was an order of magnitude over the real ceiling.
The target editor invalidated a bare ["insights"] array on save and reset.
That literal sat outside the centralised factory, so the broad insights
fan-out it intended never matched the reads, which key through
insightsTargets/insightsRoot. Route the editor's reads and invalidations
through userThresholds(), insightsRoot(), insightsTargets() and analytics()
so a saved target actually refreshes every insights surface.
Migrate the other call sites the lint rule does not reach onto the factory:
the Coach conversation rail + thread reads, the per-metric personal-records
badge, the per-day measurement drill-down, the dashboard intake
compliance-chart fan-out, the per-medication side-effects list, the
onboarding auth invalidations, the notification-preferences read, and the
PWA persister's auth-me lookup.
Add the matching factory keys: coachConversations, coachConversation,
personalRecordsByMetric, measurementDrilldown, complianceChartInline and
medicationSideEffects. Each new key reuses the prefix its reads already use
so no invalidation shape drifts.
The dev-only SHA-256 key padding was gated on the absence of a production
marker, so a staging or preview container with NODE_ENV unset silently
derived a weak key the operator never recorded — rotating to a real key
later orphaned those rows (decrypt is fail-closed). Gate the padding on an
explicit NODE_ENV of development or test; every other case, including unset
NODE_ENV, throws the same hard error as in production.
coach_messages is append-only and grows forever — a daily Coach user
accrues encrypted conversation rows with no lifecycle, while every
sibling ledger (push_attempts, audit_logs, host_metrics) already prunes.
Add a daily pg-boss cleanup that hard-deletes rows past a configurable
window (COACH_MESSAGE_RETENTION_DAYS, default 365, 30-day floor so a
misconfig cannot drop live in-context history). Wire it into the
maintenance registrar with the dead-queue contract guard and a focused
helper test.
The sliding-30-day session reaper and the passkey-challenge sweep both
filter on expires_at, but neither sessions nor auth_challenges indexed
that column, so the cleanup degraded to a sequential scan on a busy
instance. Every sibling expiry table already indexes its expiry column;
add the two missing indexes via migration 0178.
The floating Coach launcher inherited the button's dark foreground token,
which left the sparkle glyph as a dark outline on the purple-pink
gradient. The glyph is decorative and the button carries its own
accessible name, so the white mark reads cleaner without affecting the
control's non-text contrast, which the offset focus ring already
provides.