Commit Graph
2704 Commits
Author SHA1 Message Date
Marc-André Bombeck ba5a17a54e fix(daily): keep the coach check-in constants out of the client bundle
The Today hero and the check-in hook imported the check-in intent constants
from digest.ts, which — since the milestone slice — transitively reaches the
database through the streak baseline engine. That pulled the Postgres driver
into the client bundle (a Turbopack 'Can't resolve net/tls/dns' build failure
the worktree build couldn't surface). Move the four constants to a tiny,
dependency-free coach-checkin-intents module; the client imports them from
there, and digest.ts (the server builder) stays server-only.
2026-07-17 09:37:39 +02:00
Marc-André Bombeck cb883e1470 feat(dashboard): cohesion polish — hero score rings return, per-metric accents, quiet milestone moment
- Resurface the selected hero score rings on the Today hero: the Settings
  picker's selection and drag order render again as a calm ring cluster
  beneath the health ring, each ring in its wellness hue, flat-painted,
  and linking to its existing detail surface (score anatomy pages,
  medications). The health ring now opens the Insights overview like its
  cluster siblings. Empty selection keeps the hero exactly as before.
- Per-metric identity accents on the daily rail via the existing --tile-*
  hue vocabulary: a soft inset edge (stress for tension windows, strain
  for ECG pointers, primary for dose and coach cards); the same mark ties
  the ECG cross-link and the intraday pulse card shell to their rail
  siblings. Utility items (sync, preventive care) stay accent-less.
- Quiet "reached" treatment for the milestone card: a soft success halo
  over the theme card plus a one-shot spring reveal on --ring-spring,
  with its reduced-motion fallback beside the keyframes.
- The Today hero carries the one sanctioned atmosphere wash (a faint
  primary mix over the card in the wellness-tile pattern); the loading
  skeleton shares it so the swap changes content, never the surface.
- Skeleton primitive upgraded from the whole-block pulse to a single soft
  shimmer sweep, reduced-motion guarded in globals.css; dependent test
  assertions repointed.
- Ring link / dose-tally aria strings restored in all six locales.
2026-07-17 09:05:24 +02:00
Marc-André Bombeck 8ac38926c4 merge: assemble capstone signal slices (S10 ECG narrative, S11 intraday pulse, S12 milestones) 2026-07-17 08:38:44 +02:00
Marc-André Bombeck 2f1423fa09 Merge branch 'feat/cap-s11-pulse' into release/v1.29.0
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/daily/__tests__/digest.test.ts
#	src/lib/daily/digest.ts
#	src/lib/daily/load-digest.ts
2026-07-17 08:25:44 +02:00
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00
Marc-André Bombeck a33527da57 feat(insights): intraday pulse shape and elevated-at-rest tension window
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.
2026-07-17 08:16:32 +02:00
Marc-André Bombeck 8b7bb7914e feat(daily): milestones — the calm reward layer
Add the durable-states Milestone primitive and a milestone card to the
daily digest. A milestone marks a state REACHED, never days maintained:
a metric settled back inside its own range, a run of weeks in range, or
a fresh personal best. It is deliberately not a streak — the type carries
a reached-date and no running counter, so nothing here can be tuned into
a flame, and a lapse is invisible rather than mourned.

The primitive is a thin wrapper over engines that already exist: the pure
detectStreak band engine (return-to-range + sustained-in-range) and the
PersonalRecord detection output (new bests). Reached-once needs no new
storage — a milestone's reached-date is a stable fact, so it surfaces only
on the day it was reached and never resurfaces the next day.

The digest emits one calm success card per day when a milestone is freshly
reached, below an overdue dose and above ambient items, with a single
action into the metric's insight. Module- and data-gated: a disabled
insights account or a day with nothing reached shows nothing. Copy carries
no streak, loss, or failure vocabulary, in all six locales.
2026-07-17 08:10:58 +02:00
Marc-André Bombeck d470d406a7 Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle 2026-07-17 00:02:51 +02:00
Marc-André Bombeck 73de8f6a52 feat(documents): bidirectional labs ↔ documents cross-link
Two directions, folding the vault and labs pipelines into one linking path
without weakening either manual flow.

Vault document → lab values (auto-stage). After the per-document auto-index
job transcribes a stored document, a lab-looking document is run through the
same inbound extraction the manual "Extract" button uses, but the facts land
PENDING for the existing human review — nothing is committed. Gated on both
the documents and labs modules plus the auto-AI-read consent gate the
auto-index external path already uses; a text-structuring pass over the
transcription the index just produced, so no second vision call is spent. The
staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or
an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call.
Everyone without the opt-in keeps the manual extract button.

Lab scan → vault. A lab report scanned in the labs area is now also filed
into the Documents vault (kind LAB_RESULT) through the existing upload —
encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the
committed labs are cross-linked back to it with the same APPROVED-observation
provenance the manual vault → labs path produces. Text-mode scans keep the
image on-device and file nothing. Idempotent: a re-commit inserts no rows and
links nothing new, and the vault dedupe means no duplicate document.

Owner-scoped and module-gated on both sides; the cross-link is best-effort so
it never fails the authoritative write.
2026-07-17 00:01:45 +02:00
Marc-André Bombeck e272453327 chore(release): v1.28.55 — the Today view comes to life
Bundles three daily-loop slices onto the Today view:
- Coach check-in: a due plan review surfaces as a calm Today card with
  keep/adjust/let-go, wired to the existing plan lifecycle; silence never
  changes a plan.
- Morning freshness: the sleep-dependent parts of the digest and score refresh
  the moment last night's sleep arrives (a debounced, timezone-correct per-user
  job across the Withings, WHOOP and Apple transports), with an honest
  provisional state until then. Adds migration 0244 (a per-user refresh marker).
- Daily push: an opt-in, once-a-day morning notification carrying the day's
  read, off by default, riding the existing dispatch cascade — a calm note,
  never an alert.

All read the overnight digest; nothing generates on open.
2026-07-16 23:33:33 +02:00
Marc-André Bombeck 502783a3ea feat(coach): attach multiple documents to a conversation
Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.

The fence is re-established at both enforcement points, fail-closed:

- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
  fresh chat with staged attachments) to the new /api/insights/chat/fenced
  endpoint; the fenced body is strict and never carries scope, guided-question,
  or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
  fetched row still carries an attachment (flag/join drift), emitting a
  fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
  code path — the fenced pipeline — with no tools and no health snapshot; a
  module-graph test pins that the tool loop is unreachable from it.

The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.

Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.

Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
2026-07-16 23:32:28 +02:00
Marc-André Bombeck 04cb80da6a feat(daily): a calm opt-in morning briefing push
Add a once-daily briefing notification that reads the cached daily digest
and never makes a fresh model call. It rides the existing dispatch cascade
(APNs, Telegram, ntfy, webhook, email, web push), so it inherits the
hard-reject classification and the push-attempts ledger unchanged.

- New DAILY_BRIEFING event type, default off: the briefing stays silent
  until the user opts a channel in from the notifications matrix, so the
  channel choice is explicit and a fresh account never gets an unasked-for
  push.
- One decision seam both triggers share: the sleep-arrival morning refresh
  fires it the moment the day finalises, and a fixed local-morning fallback
  slot covers the case where last night's sleep never synced. Whichever
  wins the morning writes the ledger row; the once-per-local-day cap
  suppresses the other.
- The body is the digest line — the cached briefing lead when present, else
  the deterministic floor — so a keyless self-hoster still gets a
  first-class push. Provisional days carry an honest "sleep not in yet"
  note.
- Non-clinical and non-urgent by construction, fired only inside a local
  morning window: it is a nudge, never the anomaly channel, and never
  bypasses Focus. Empty accounts are left alone rather than nudged with a
  hollow all-clear.
- Localised across all six locales.
2026-07-16 23:23:41 +02:00
Marc-André Bombeck b6ded4d1f9 feat(daily): finalise the morning digest when last night's sleep lands
The nightly insight pre-pass runs ~04:30, before last night's sleep has
synced, so the morning digest and health score reference the day without
the current sleep. Close the gap the event-driven way instead of an
on-mount generation.

A day's digest is now provisional until the sleep-dependent output has
been regenerated with last night's sleep, and final after. The lifecycle
is driven by a real marker (User.morningDigestRefreshedOn, the user-local
date the refresh completed), not a heuristic: it flips the instant the
refresh runs, ahead of the snapshot cache that feeds the sleep last-seen
fallback. A day with no sleep device stays honestly provisional and keeps
the sleep-pending note rather than showing a stale final score.

When a completed sleep segment for last night lands from any of the three
sleep transports — Withings sync-sleep, the WHOOP sync, the Apple-Health
measurement batch — a debounced morning-digest-refresh job is enqueued.
"Last night" is judged in the user's profile timezone, so a full-history
re-sync never re-triggers for an old night. The queue singletonKey plus
the marker collapse the many samples of one night, across all sources,
into one refresh per user per local morning; the job re-runs the same
comprehensive pipeline the nightly cron uses (forced, so the content-hash
gate turns a no-change regen into a cheap no-op) and stamps the marker. A
hard generation failure leaves the day provisional and hands recovery to
the existing 45-minute briefing-retry machinery.
2026-07-16 22:57:27 +02:00
Marc-André Bombeck d72f078764 feat(documents): open the doc-scoped Coach in the side drawer, not a full page
"Ask the Coach" on a document used to navigate to /coach?doc=<id> — a full-page
jump away from the vault. It now opens the real, doc-scoped conversation in the
shared side drawer, and the drawer's maximize control expands it to the full
/coach page while preserving scope.

- Thread an optional documentId through the launch context: askCoach gains a
  fourth argument, the provider tracks it alongside prefill/scope and clears it
  on close, and LayoutCoachMount forwards it to the drawer as initialDocumentId.
  The open-state precedence moves into a pure resolveLaunchState helper.
- The vault detail sheet closes itself and calls askCoach(..., doc.id) instead
  of routing away, so the drawer owns the surface.
- Maximize preserves continuity: the drawer reads the live conversation id via
  a new imperative getter out of the conversation surface (parallel to the
  reset registration) and hands off to /coach?c=<id> once a thread exists,
  falling back to /coach?doc=<id> for a chat maximized before its first turn.

The send path is untouched: every document turn still routes through the
hardened fenced endpoint keyed on documentId — only where the conversation
renders (drawer vs page) changed. The documents-chat e2e now asserts the
drawer opens in place, doc-scoped, rather than a URL change.
2026-07-16 22:34:17 +02:00
Marc-André Bombeck b7a6b4c898 feat(coach): close the plan check-in loop on the Today rail
Every accepted coach plan now earns a gentle weekly check-in. Confirming a
plan (proposed → active) without an explicit review date defaults one to a
week out, so a plan is always reviewed, not only the coach's own experiments.

The daily digest emits a single `coach_checkin` rail item when a standing
plan's review has come due — its pinned review date, or a defaulted
activation + 7 days for plans that predate it, or the read-back state a
reviewed plan carries. Capped at one check-in a day across all plans
(earliest-due wins); the rest wait for a following day. It reads existing
plan state only — no fresh generation, nothing warms on mount, and it is
gated on the coach module.

The card offers three one-tap actions through the existing plan-lifecycle
route: keep it (re-arm for another week), adjust it (open the coach), or let
it go (a guilt-free, respected end — the calm inversion of a streak). An
ignored check-in sits quiet and stops resurfacing after about two cycles;
its status is never changed behind the user's back.

Copy lands in all six locales.
2026-07-16 22:30:46 +02:00
Marc-André Bombeck 1d592bef78 refactor(dashboard): retire the legacy opt-in hero
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
2026-07-16 21:34:06 +02:00
Marc-André Bombeck 08a3ae2ef1 fix(dashboard): pin the Today hero to the skeleton during hydration
The digest query rehydrates from the persisted client cache, so the Today
hero was painting on the first client render while the server had rendered the
skeleton — a text-content hydration mismatch (React #418) that the dashboard
e2e's no-significant-errors gate caught. Gate the hero on `mounted` like the
rest of the dashboard's late-hydrating surfaces, so SSR and the first client
render both show the skeleton and the hero appears once mounted.
2026-07-16 20:03:58 +02:00
Marc-André Bombeck 0be26473b4 fix(dashboard): keep the legacy hero rendering as before
Revert the Today-hero-owns-the-slot suppression from this branch: the legacy
opt-in dashboard hero is asserted by the mobile-density and dashboard e2e
landmarks, and retiring it cleanly (with its settings toggle and those specs)
is a separate change. For now both heroes render exactly as before this branch;
the default account (legacy hero off) still shows only the Today hero.
2026-07-16 19:49:56 +02:00
Marc-André Bombeck 25d720e6e7 chore(release): v1.28.53 — a Today overview at the top of the dashboard
Bundles the daily-digest spine (a single server-built digest read from the
overnight insight cache, never a fresh generation) and the Today hero that
surfaces it: the health score with an honest provisional/finalised face, the
day's lead read, and a short worth-a-look rail of one-tap items. The tiles and
charts stay below, unchanged. The Today hero takes the promoted slot when
Insights is on, so it never stacks with the legacy opt-in hero.

This is the shared foundation the later daily surfaces (morning refresh, an
optional daily note) build on — one digest, one card model, one design language.
2026-07-16 19:37:14 +02:00
Marc-André Bombeck 168c1c85d7 feat(dashboard): promote a Today hero above the tile strip
Surface the day's read at the top of the dashboard: a Today hero that
renders the daily digest — the health score with an honest
provisional/final and sleep-pending face, the briefing lead with a
read-the-full-briefing affordance, and the worth-a-look rail of priority
cards. The dense tile grid and chart rows stay exactly where they are,
one scroll below, so muscle memory survives and no new nav destination
is added.

The hero reads one cached digest cell (GET /api/daily/digest) through the
centralised query-key factory and unwraps the envelope; it recomputes
nothing and never warms a provider on mount. It is gated on the insights
module and self-gates its empty state: a genuinely empty account renders
nothing rather than an alarming card, while an account with a score but
nothing notable gets a calm all-clear line in the rail's place. A shimmer
skeleton holds the footprint while the digest loads.

Priority-card actions wire to their existing destinations by construction
(log dose, reconnect a sync, view check-ups). New copy lands in all six
locales.
2026-07-16 19:31:13 +02:00
Marc-André Bombeck ba6c0990bf feat(daily): add the daily-digest spine and priority-card primitive
Introduce the two foundational primitives of the unified daily-value
system, with no user surface yet:

- PriorityItem: the one wire-serialisable model behind every "worth a
  look" rail item, coach check-in, milestone, and device-verdict pointer.
  Its Lucide glyph is derived from the closed `kind` enum in the renderer
  so the model itself stays JSON-serialisable across the API and future
  native-client boundaries.
- PriorityCard: a calm, token-only, reduced-motion-aware card composed
  entirely from the shipped Card / TileHeader / ProseBlocks primitives —
  no new visual language, semantic-token status wash only, no markdown.
- buildDailyDigest: one pure, deterministic server composer that assembles
  the day's read from data that already exists — the nightly briefing
  lifted read-only from the insights cache plus the dashboard-snapshot
  health score, meds-today, and sleep freshness. It computes no analytics
  and never reaches an AI provider. It emits the DailyDigest DTO the Today
  surface, the daily push, and a future iOS widget will all consume.
  Deterministic item builders cover overdue doses, broken integrations,
  and due Vorsorge reminders; the push line prefers the cached briefing
  lead with a deterministic score floor so a keyless self-hoster still
  gets a first-class line.

The read seam GET /api/daily/digest gathers the cached inputs, gates on
the insights module (cookie or Bearer; userId from the session), and
returns the digest. OpenAPI regenerated; the route is covered by the
module gate inventory. Digest strings ship in all six locales.
2026-07-16 19:13:01 +02:00
Marc-André Bombeck 7c45c876a0 feat(dashboard): tiles for HRV, blood oxygen, breathing rate, temperature, and body composition
Several readings were synced and charted but had no place on the dashboard
strip: heart-rate variability, blood oxygen, breathing rate, wrist temperature,
and — for body-composition scale users — muscle mass, body water, and bone mass.
Each now has a tile.

The tiles are pure client additions: the dashboard snapshot already ships a
summary for every measurement type, so no backend, query, or migration changed.
Each tile is data-availability gated (renders only when the account has that
reading) and is toggleable and reorderable under Settings like the rest.

hrv and respiratoryRate graduate from iOS-only to web-writable widget ids, and
muscleMass is a new writable id (catalogue 36 to 37, within the 40-id PUT cap);
wristTemperature moves from pin-only to a web-rendered tile. All additive — the
widget enum only widens, which the iOS contract treats as non-breaking.
2026-07-16 15:21:03 +02:00
Marc-André Bombeck 5440ac9070 test(e2e): repoint document-chat spec to the Coach hand-off
The bespoke document-chat drawer was removed with Design A, so the old e2e
asserting the drawer overlay no longer applies. The spec now pins the
documents-side contract that replaced it: with a provider available the Coach
affordance hands off to the document-scoped Coach (/coach?doc=<id>), and with
no provider the AI area shows only the settings pointer and no Coach entry.

The Coach-side scope banner (the Document badge, "Chatting about", and the
not-indexed hint) moves to a coach-conversation component test, replacing the
drawer's not-indexed/render coverage; the fenced-send guarantee stays pinned by
the coach-send-target unit test.
2026-07-16 11:24:22 +02:00
Marc-André Bombeck 5b24ca0fa1 feat(coach): document chats live in the Coach view and history
Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.

The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.

The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
2026-07-16 11:01:59 +02:00
Marc-André Bombeck 49ed0f41df feat(insights): view ECG recordings
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.

The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
2026-07-16 09:55:35 +02:00
Marc-André Bombeck 0f4e0be1a6 chore(release): v1.28.49 — Apple Health re-import survives a natural-key collision
The Apple Health ZIP import upserted each spot reading on the
(userId, type, source, externalId) index only. When two readings shared the
natural key (userId, type, measuredAt, source, sleepStage) with different
externalIds — a re-import of overlapping data, or two same-instant Apple
samples from different source apps — the externalId upsert missed and the
create collided on the second unique index (migration 0055), raising P2002
and aborting the entire import (issue #486, after the v1.28.36 dedup fix let
the upload run).

flushSpotBatch now wraps the per-row upsert: on P2002 it probes the occupying
row by the natural key (tombstone-inclusive) and adopts it in place — re-keys
to the incoming externalId, takes the new value/unit, resurrects if
soft-deleted — mirroring the v1.28.39 provider natural-key rescues (closest to
withings/sync-sleep). If no twin resolves it, the one reading is skipped and
the run continues; the catch sits in the per-row loop outside any transaction,
so a collision can never abort the batch or the import. Adopted rows count as
updated, skipped ones surface honestly. Workout + stats: paths verified unable
to hit this (no second unique index / deterministic externalId). Repro +
real-Postgres integration tests added.
2026-07-16 08:48:41 +02:00
Marc-André Bombeck a9a318d4a4 chore(release): v1.28.48 — security follow-ups
From the campaign's security re-verification pass (posture otherwise held
across the 10-release week — no Critical/High):

- MEDIUM: the OIDC auto-provision branch re-introduced the first-admin race
  the password-register route fixed in v1.28.42 — count-then-create with no
  lock, so two concurrent OIDC first-logins (or an OIDC first-login racing a
  password first-registration) in the empty-DB window could both mint ADMIN.
  Now wraps count+role+create in a $transaction taking the SAME advisory lock
  key ('register:first-admin') and re-counts inside, so the two paths
  serialise; every provisioned field preserved.
- LOW: the nutrients batch catch echoed the raw exception message into the
  client-facing reason (violating its closed reason-set). Now returns the
  fixed 'upsert_failed' member and logs the detail server-side only.

No migration (advisory lock is runtime).
2026-07-16 08:03:12 +02:00
Marc-André Bombeck 656e96f72b chore(release): v1.28.46 — server performance on large instances
Deferred perf fixes from the campaign's verified perf-correctness audit,
correctness-first (each with an output-equivalence test):

- comprehensive-generate fan-out: replaced per-type 400d findMany+JS-average
  with DB prisma.aggregate(_avg) over the exact half-open windows the JS
  reducer used (current 30d, lastMonth (60d,30d], lastYear (395d,365d]), same
  rounding + null-on-empty, pLimit(4). SLEEP_DURATION stays on the raw path
  (per-night reconstruction dedup). Byte-equality test vs the old algorithm.
- boot-discovery: replaced Prisma in-memory distinct with DB SELECT DISTINCT
   in the 4 backfill discovery scans + migration 0243 (7 additive
  partial indexes matching each predicate). Integration test asserts identical
  discovered set against real Postgres.
- ChatBubble wrapped in React.memo with a comparator (onRegenerate compared by
  presence — the thread rebuilds it each render), so only the streaming bubble
  re-renders per token.
- doctor-report mood read narrowed to {score, tags} (the only fields the
  collector uses); the 730d main measurement read left as-is — a blind bound
  would corrupt clinical stats (documented deferred follow-up).

Migration 0243 additive-only.
2026-07-16 06:47:36 +02:00
Marc-André Bombeck 0796d9fe14 chore(release): v1.28.45 — doctor-report + privacy fixes
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:

- Sleep rendered nothing in the doctor-report PDF: the sleep section is
  default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
  DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
  nothing (FHIR already emitted it). Added it to the allowlist with a
  minutes-to-hours conversion; corrected the stale 'intentionally excluded'
  comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
  glucose module was on, with no report-level privacy control. The toggle
  actually already existed in the UI (with i18n) but was dead because the prefs
  fold dropped the key. Wired it end to end (glucose added to the prefs Zod
  schema + defaults, default-on to preserve behaviour; gated the render loop +
  the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
  GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
  grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
  it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
  skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
  five sources.

Regression test per fix.
2026-07-16 05:51:04 +02:00
Marc-André Bombeck cd0fbddd1d chore(release): v1.28.44 — loose ends: broken link, API contract, module guards
From the campaign's dead-code + code-health audits (grep-verified; the
custom-metrics query-key finding was verified-false and excluded):

- sleep-overview empty-state CTA linked to /settings/devices, not a valid
  settings slug (dynamicParams=false → 404). Repointed to
  /settings/integrations (where every device OAuth callback already lands).
- six live, iOS-consumed routes were absent from docs/api/openapi.yaml —
  auth/check-user, insights/cards, integrations/healthkit (GET+PATCH),
  measurement-categories, medications/intake/bulk, mood-entries/bulk —
  registered in the Zod OpenAPI registry mirroring each handler's real shape.
- mood/labs/medications module pages lacked the redirect-when-off self-guard
  their siblings have; added, mirroring cycle/achievements exactly.

No migration. Verifier REJECTED the custom-metrics bare-key finding (they are
invalidateQueries prefix filters, not colliding useQuery keys).
2026-07-16 04:59:27 +02:00
Marc-André Bombeck 6d83115d16 chore(release): v1.28.43 — visual consistency polish
Verified UI-consistency findings from the campaign's design audit, each
pixel-faithful except the intended correction:

- insights hero band: off-scale py-5/gap-5 → the standard scale; H1
  font-semibold → font-bold; sm:text-[28px] → sm:text-3xl.
- medication-compliance-chart + recent-achievements-card: hand-rolled
  muted h-4 icon + text-sm headers → the standard TileHeader primitive so
  they match sibling tiles.
- achievements stat tiles + clinician-view shells: p-5 → p-4 md:p-6.
- two text-primary header glyphs (research-mode dialog, enroll-mfa) →
  text-foreground (never accent on a header glyph); the deliberate
  badge-boxed icons left untouched.
- SheetHeader now reserves the trailing close-X gutter like DialogHeader,
  closing the latent overlap class for future raw-Sheet consumers.
- five raw Dialogs on phone-reachable surfaces → ResponsiveSheet.
- new bg-card-scoped healthlog/spacing-scale lint (flags p-5/gap-5 etc.
  only on card shells; list-marker insets + half-steps excluded) at error,
  with the five offenders it flagged swept.
- dropped the stale CLAUDE.md '18 PascalCase outliers' line (0 remain).

Every data-slot preserved. No migration.
2026-07-16 03:44:00 +02:00
Marc-André Bombeck d4be568625 chore(release): v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
Performance + correctness fixes from the campaign's verified perf-correctness
audit (the two server-query findings — comprehensive-generate + boot-discovery
distinct — are deferred to a careful separate perf release):

- format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call
  (main-thread stall across every history list). Routed through the existing
  process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle
  in the key); output byte-identical.
- measurement-list rendered every row twice (desktop table + mobile cards, one
  CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe
  useIsMobile(md) hook; row markup + every data-slot unchanged.
- moodDependentKeys omitted dashboardSnapshot(), so a mood write left the
  dashboard tile/score stale ~120s (server cache was already correct). Added it.
- register first-user admin was a count-then-create race (two concurrent
  first sign-ups could both become ADMIN). Now an advisory lock + in-transaction
  re-count makes first-admin atomic; P2002 maps to 409 instead of 500.

Regression test per fix. No migration (0243 still free).
2026-07-16 03:18:55 +02:00
Marc-André Bombeck 45fb909c16 chore(release): v1.28.41 — complete the translations, guard the gap
illness.vital.FUNCTIONAL_IMPACT (the recovery-driver track that wins ties
in the illness insights card's driver selection) was referenced but absent
from all six locale bundles, rendering its raw dotted key on a live
surface — the same dynamic-key seam that let the WHO-5/SCI reminder labels
ship untranslated. Added the label in de/en/es/fr/it/pl.

Closed the class structurally: a new enum-driven
dynamic-key-exhaustiveness test imports the source enum/const for each
high-value dynamic key space (illness scan types + functional-impact,
illness type/lifecycle, allergy + family-relationship records enums, the
mental-health instrument slugs — the WHO-5/SCI seam) and asserts every
member's built key resolves non-empty in all six locales, so a missing
translation fails CI instead of reaching a user. Fallback-protected spaces
(Strava raw sport_type) and structurally-unreachable keys are documented
as excluded. Removed one confirmed-orphan key (illness.vital.SKIN_TEMPERATURE,
zero call sites).
2026-07-16 02:35:22 +02:00
Marc-André Bombeck 461c1cfe09 chore(release): v1.28.40 — insight assessments lead with meaning, not a number
The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).

Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
2026-07-16 02:14:46 +02:00
Marc-André Bombeck 7386630962 chore(release): v1.28.39 — sync data-loss hardening
Four verified HIGH data-integrity fixes, each in-place per-provider (no
shared helper), from the campaign's data-integrity audit + adversarial
verification:

F1 — drain-per-sample-cumulative buildScanWhere was the only one of four
sibling drains missing deletedAt: null; the nightly cumulative fold
re-summed soft-deleted Apple samples and hard-deleted the legacy-steps
audit tombstones. Added the filter (mirrors consolidate-daily-mean,
dense-intraday-retention, consolidate-legacy-steps).

F2 — the additive late-fold merge had no single-flight guard, so two
overlapping runs (deploy overlap, or the admin endpoint racing the cron)
produced a permanent 2x daily total. Added a pg_advisory_xact_lock per
(user, externalId) plus an in-transaction re-read of the live
contributors so the serialised-second fold contributes zero (a bare lock
alone would still re-add its stale scanned sum). Mirrors the documents
quota advisory-lock pattern.

F3 — Withings sleep externalId keyed on segment START while measuredAt +
the natural key use END; a re-scored segment missed the externalId probe,
the sweep tombstoned the old row, and the re-keyed insert collided on the
natural key and wedged. Added the natural-key rescue (re-key in place),
mirroring the same-file measure path.

F4 — Withings measure sync stamped lastSyncedAt + recordSyncSuccess
unconditionally after a warn-only per-row loop; a transient write failure
stranded the reading behind the 10-min overlap forever. Now holds the
watermark and records failure on a hard row error (mirrors
google-health/fitbit), while a benign P2002 still advances.

Regression test per finding (reverting the three source files fails
exactly four tests). Integration suite green against real Postgres.
2026-07-16 01:45:05 +02:00
Marc-André Bombeck 3e917969c7 fix(documents): surface the read-by-AI provenance in the detail view
v1.28.38 removed the purple AI-read glyph from the vault card (it looked
wrong there), but that glyph had been the only visible surface for the
'read by AI' provenance since v1.28.22 — the detail sheet's AI section
shows no status and collapses entirely under auto-read. Add a discreet
muted 'Read by AI' line under the document's uploaded-metadata in the
detail view (data-slot document-detail-ai-read), independent of the AI
section's collapse logic; reinstate documents.ai.statusAiRead in all six
locales. Card glyph stays removed. The e2e that asserted the card marker
now deep-links the document and asserts the detail-view line.
2026-07-16 00:54:01 +02:00
Marc-André Bombeck 4852421f05 chore(release): v1.28.38 — documents polish, waist reminders, bulk sharing
Document vault: card list drops a stray glyph and gives the real filename
its own line (was truncating on the meta row); the detail sheet's close
button aligns correctly — ResponsiveSheet reserved the right gutter for a
close button it wasn't rendering, now conditional on showCloseButton. The
open-in-coach action reads 'Coach fragen'.

Bulk share: a multi-selection in the overview shares several documents in
ONE link (documents-only privacy preserved, owner-scoped, up to 50 per
link; >50 refuses with a toast rather than silently truncating).

Reminders can link to WAIST_CIRCUMFERENCE (additive MeasurementReminderType
enum value, OpenAPI regenerated). The WHO-5 and SCI well-being reminder
labels were absent in all six locales and rendered their raw dotted keys
(the shared 'measurementReminders.types' prefix is what showed as the stray
'Measurement Reminders Types'); added in every language. A new
type-labels-i18n test asserts every dynamic reminder-type key resolves
non-empty in all six bundles — the static coverage guard cannot see the
dynamic key that let this through.
2026-07-16 00:33:20 +02:00
Marc-André Bombeck 0ba5334b57 fix(measurements): legacy step consolidation no longer sweeps provider daily totals
WIP-commit (worktree safeguard; final assembly + version bump happen in the main tree after v1.28.36 merges).
2026-07-15 22:49:07 +02:00
Marc-André Bombeck 82aa66b400 chore(release): v1.28.36 — re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup + staging fixes were correct but never reached the
reporter's case: the upload dedup keys on the content SHA-256 and the
lookup matched a previously FAILED job too, so re-uploading the same
export.zip returned the dead job's id and replayed its stale
failureReason without ever staging or sending again. The dedup now
excludes failed rows (short-circuits only on queued/unpacking/parsing/
upserting/done); the same bytes retry cleanly. A redundant staged upload
is unlinked on a dedup hit instead of leaking in the staging dir.

reconcileOrphanImportJobs no longer unconditionally fails every
non-terminal ImportJob at boot: it now checks pg-boss job state plus a
progress heartbeat (new import_jobs.updated_at, migration 0242), so a
split or multi-worker deployment cannot kill an import running in
another worker. The missing-staging message names the separate-container
cause explicitly, with a matching note in docs/self-hosting/scaling.md.
2026-07-15 22:07:25 +02:00
Marc-André Bombeck a614542cc3 Merge pull request #485 from balajiv113/feat/oidc-sso-login
feat(auth): add OIDC SSO login
2026-07-15 21:37:20 +02:00
Marc-André Bombeck 9bbd8a89dc chore(release): v1.28.35 — timestamps honor the profile timezone
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).

Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
2026-07-14 20:19:23 +02:00
Marc-André Bombeck 5445fc67ea chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
2026-07-14 13:10:47 +02:00
Marc-André Bombeck 6d8fe06148 chore(release): v1.28.33 — re-importing a cumulative Apple Health export works again
Rollup writes were delete-then-createMany inside a transaction; under READ
COMMITTED a concurrent recompute racing the import's full fold on the same
(user, type, granularity, bucket, source) partition tripped the composite
PK — and the P2002 swallow dropped the loser's freshly computed aggregate,
leaving a stale bucket. persistRollupRows now writes chunked
INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket)
lock ordering inside one transaction: a race can neither fail the import
nor lose an aggregate, and previously-staled buckets self-heal on their
next recompute. The vanished-source delete and in-transaction guarantees
are preserved; the now-unreachable P2002 swallow is removed.

The masked ENOENT is gone with it: long imports outlived the queue's
15-minute expiry, pg-boss redelivered the still-running job, and the
redelivery re-opened the consumed staging file — overwriting the real
terminal state with a raw ENOENT. Import sends now carry retryLimit 0 +
6h expiry (a retry after the staging file is consumed can only mask),
deliveries for terminal ImportJob rows are no-ops, a genuinely missing
staging file reports an honest re-upload message, and the extracted XML
is cleaned up on failure too.
2026-07-14 11:56:29 +02:00
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00
Marc-André Bombeck 9b8b31c864 fix(auth): OIDC sessions never satisfy MFA step-up; 2FA accounts get the challenge step
Two second-factor gaps in the OIDC callback:

The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.

An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).

Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
2026-07-14 11:46:40 +02:00
Marc-André Bombeck fa326c398a feat(auth): pin OIDC accounts to their (issuer, sub) identity
An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.

The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.

Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
2026-07-14 11:43:53 +02:00
Balaji Vijayakumar f815e4a83f feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.

Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck a70139c16a chore(release): v1.28.32 — Fitbit re-keyed re-imports can no longer be silently dropped
Single-fix release mirroring the Google Health natural-key rescue from
v1.28.28 into the Fitbit sync. The measurements natural-key unique index
(user, type, measured_at, source, sleep_stage — NULLS NOT DISTINCT,
migration 0055) covers tombstoned rows, so a re-import carrying NEW
externalIds for previously-removed rows collided on the natural key and
skipDuplicates dropped the insert silently. Every planned create now gets
a second chunked probe by natural key with no deletedAt filter; a match —
live or tombstoned — is updated in place with the fresh value, the new
externalId, and deletedAt: null. Probe failure notes the hard-failure
ledger so the watermark holds and the next tick retries. No other code
path touched.
2026-07-12 20:30:12 +02:00
Marc-André Bombeck 1375d20a9d chore(release): v1.28.31 — heart-rate history keeps its daily shape
Long-term retention for the dense intra-day tier (PULSE, HRV, SpO2) folds
to per-local-hour means instead of a single daily mean: the last 14 days
keep every raw sample, older days keep up to 24 points per metric — the
day's shape survives permanently at ~9k rows/metric/year. Hourly rows key
as stats:<HK>:<YYYY-MM-DD>T<HH> anchored at local HH:30, adopt-in-place
against both unique indexes with the P2002 retry outside the transaction.
The pre-fold DAY-rollup recompute extends to HRV (mean fidelity, not only
min/max) and the post-fold recompute is gone for the whole tier. Derived
resting rows for proxy users still mint from the day's raw samples.

A one-shot rebuild reconstructs the hourly shape for every already-folded
day from the soft-deleted raw samples and retires the daily row in the
same transaction — at no instant are both grains live, so no
AVG-over-live-rows reader can double-count. Boot discovery pairs live
daily-grain rows with surviving tombstones (parameter-bound SQL, pinned
against real Postgres) and converges to zero once converted; days whose
tombstones were pruned keep their daily mean. PULSE/SpO2 DAY buckets keep
the true pre-fold stats; HRV DAY buckets recompute from the hourly rows
(strictly better than the collapsed fold-time bucket); WEEK/MONTH/YEAR
respans enqueue whole-bucket recomputes.
2026-07-12 12:07:41 +02:00
Marc-André Bombeck a4feabec81 chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
2026-07-12 11:08:59 +02:00