Commit Graph
891 Commits
Author SHA1 Message Date
Marc-André Bombeck accd7c8514 fix(measurements): merge MANUAL and Apple Health rows for the same reading
A measurement logged by hand on the iOS app while offline is mirrored
into HealthKit. On pairing a server, adopt-on-pair uploads it as a
MANUAL row while HealthKit background sync independently re-ingests the
same sample as an APPLE_HEALTH row. The two carry different external ids
and sources, so the composite unique index keeps both — doubling the
whole hand-logged history on first pair.

Collapse the pair at ingest: an incoming MANUAL or APPLE_HEALTH row is
dropped as a duplicate when a same-reading row of the opposite source
already exists (same type and value, measuredAt within two seconds).
The match is scoped to that one cross-source pair, so same-source rows
and rows from server-owned sources keep their existing contracts, and
two genuinely distinct readings at the same minute never collapse.
stats:* cumulative rows stay on their per-day overwrite lane.

The collapsed copy returns status "duplicate" with reason
"cross_source_merge" so the client cursor advances unchanged; no
schema, migration, or OpenAPI change.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck b54b95dbc7 fix(insights): bound the mood time-of-day chart height
The "mood by time of day" card spans the full overview width, unlike its
distribution/weekday siblings which sit in a two-column grid. Sizing the
chart wrapper with aspect-[3/2] derived the height off the full card width,
so on a wide viewport the chart ballooned to roughly 800px tall and
overflowed its card. Swap the aspect ratio for a fixed bounded height so the
chart fits the card at the same size as the width-constrained sibling charts
regardless of viewport. Chart internals and tokens are unchanged.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck 42945efa04 fix(insights): stop clipping long wellness-score ring labels
The wellness-score ring tiles passed the metric title into the ScoreRing
as an in-SVG centred <tspan>. A long localised title (Schlaf-Score,
Bereitschaft) was wider than the small ring's clear centre, and because
the text is SVG-centred inside the recharts overflow:hidden svg the leading
glyph was clipped (the missing leading "S" on Schlaf-Score). Render the
title as an HTML caption below the ring instead, where it wraps within the
tile and never truncates; the ring keeps just the number centred. The
in-SVG label slot stays for the anatomy detail view, which only ever passes
the short "/100" string.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck 65b988c686 fix(settings): right-align danger-zone action buttons beside their labels
The data-reset and account-delete cards under Settings → Advanced
stacked their destructive button below the description. Move the
button to a right-aligned slot on the same row as the title and
description, matching the stack-on-mobile / right-align-on-desktop
contract the onboarding-tour and about cards already use
(flex-col gap-3 sm:flex-row sm:justify-between, button w-full sm:w-auto).
The AlertDialog confirmation, i18n keys, and destructive styling are
unchanged.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck b99f2234a2 fix(whoop): reject non-positive or non-finite body-measurement values
WHOOP body fields are self-reported profile data; guard mapBody against
absent, NaN/Infinity, or non-positive values so a garbage reading never
seeds a WEIGHT measurement or a profile height of 0.
2026-06-04 15:57:14 +02:00
Marc-André Bombeck 1a88a3e6cf refactor(whoop): single-source the collection-403 soft-skip handling
Extract handleCollectionFetchError so the five resource syncs share one
soft-skip-vs-rethrow path instead of five copies. Behaviour is unchanged:
a per-resource 403 returns 0 and keeps the connection connected, anything
else records a classified failure and rethrows.
2026-06-04 15:57:14 +02:00
Marc-André Bombeck a8c0a30f29 fix(dashboard): skip prefilled date pickers in the quick-entry dirty check
A freshly-opened quick-entry sheet prefills its datetime-local field with
the current time, so the dismiss-time dirty check read every pristine
sheet as unsaved and always raised the discard confirm. Exclude
date/time picker types from the scan; typed text and notes still count.
2026-06-04 15:57:14 +02:00
Marc-André Bombeck 76426a2f2e fix(dashboard): match the tile skeleton to the resting tile height
The tile-strip skeleton and per-tile Suspense fallback reserved only 6rem,
shorter than a populated TrendCard (~8rem), so the strip grew when data
landed. Bump both to 8rem and mirror the card's overflow-hidden chrome on
the fallback to keep first-paint layout shift at zero.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck e591137162 fix(dashboard): confirm before discarding a partly-filled quick-entry sheet
An accidental overlay tap, Escape, or mobile swipe-down on a quick-entry
sheet silently dropped any typed measurement, mood, or intake. Detect
unsaved input at dismiss time and ask before closing; an explicit Cancel
or a successful save still closes straight away.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck ad3bbd5507 fix(settings): show a friendly message when an export download fails
The export cards rendered the raw "Download failed (500)" error string to
the user. Map the failure to a localized message across all six locales.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck eddc06d135 style(settings): align the mobile passkey card padding with sibling surfaces
The mobile passkey list rows used a tighter p-3 while every other card
surface in the account section uses the standard padding step. Bump to p-4
so nested card surfaces read consistently; radius stays rounded-lg.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck 51959a4737 fix(settings): give the API-token revoke button an accessible name and a larger target
The revoke icon button announced only "button" to screen readers and sat
at a 32px target. Add a localized aria-label and bump it to 36px.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck 16b7306243 fix(insights): surface an error toast when recommendation feedback fails
The thumb-up / thumb-down POST used to fail silently — on error the
buttons just reappeared with no signal that the submission had not gone
through. Add a lightweight localized error toast on failure while keeping
the success path quiet (the inline confirmation row is signal enough
there).
2026-06-04 15:36:20 +02:00
Marc-André Bombeck 0baad1e736 refactor(insights): share one block skeleton across dynamic loaders, anchor the warm-assessments control
The six `next/dynamic` loaders on the insights overview each rendered a
bespoke `<div className="… h-[Xrem] animate-pulse …" />` with a guessed
fixed height. Those heights pinned each placeholder taller or shorter than
the resolved block, so the page shifted as each chunk landed. Route every
loader through one shared `BlockSkeleton` built on the `Skeleton` primitive
(which already honours reduced motion) and hold the row open with a
`min-h` floor rather than a hard-coded pixel guess, removing the
resolve-time layout shift. Decorative placeholders for the cards that can
un-mount stay `aria-hidden`.

Anchor the warm-assessments control too: it floated right-aligned with no
label, reading as a context-less affordance. Pair it with a left-aligned
caption that explains the nightly-refresh model so the button has a home.
2026-06-04 15:36:20 +02:00
Marc-André Bombeck e63fae564b test(insights): pin the reduced-motion guard for .animate-insight-in
The 400 ms `.animate-insight-in` entrance keyframe is applied across five
insight surfaces and is collapsed to `animation: none` once, centrally, in
a `@media (prefers-reduced-motion: reduce)` block in globals.css. Extend
the motion-reduce coverage test to assert that central guard so a future
edit cannot silently drop it and replay the entrance motion for
motion-sensitive users.
2026-06-04 15:36:20 +02:00
Marc-André Bombeck 60b86f1ec9 fix(insights): suppress recommendation-card hover lift under reduced motion
The recommendation cards carry `transition-all md:hover:-translate-y-0.5`
for a subtle lift on pointer hover. Motion-sensitive users got the full
transform with no opt-out. Pair the hover with
`motion-reduce:transition-none motion-reduce:hover:translate-y-0` so the
card stays put when the user asks for less motion.
2026-06-04 15:36:20 +02:00
Marc-André Bombeck fb4037ed96 fix(coach): tidy the message thread type scale, bubble width and empty state
Lift the disclaimer and feedback-thanks lines off the off-scale 10px
size onto the standard extra-small step. Unify the user and assistant
row gap, and budget the avatar column out of the bubble's 80% cap so a
bubble plus its avatar never overflow a comfortable width on a narrow
phone. Announce the empty-thread hero as a polite live region so screen
readers read the hint when the thread first mounts.
2026-06-04 15:36:16 +02:00
Marc-André Bombeck b12dc1e870 perf(coach): parallelise the cold-path snapshot reads
The snapshot builder ran its independent reads sequentially on a cache
miss: feature extraction, the shared measurement query, the mood,
compliance, sleep and workout table reads, and the GLP-1, derived,
trajectory and memory helper blocks each awaited in turn. None consumes
another's result, so they now fire concurrently and resolve in batched
hops — feature extraction and the measurement read first (both gate the
per-metric blocks), then the remaining reads in one Promise.all. The
source guards are unchanged, so a disabled source still issues no query,
and the synchronous block assembly keeps its original order, so the
provenance envelope is byte-for-byte the same. Cuts several round-trips
off the cold build; the warm path is cached and unaffected.
2026-06-04 15:36:16 +02:00
Marc-André Bombeck 2e3bfbb971 feat(coach): add a stop control and abort the stream on drawer close
While a reply streams the composer now swaps the send button for a
visible Stop control bound to the abort handler, so a long or off-track
reply can be interrupted instead of waited out. Closing the drawer
mid-stream also calls the cancel handler, so the SSE request no longer
keeps running in the background after the drawer is gone.

Add a localised "Stop" label across all six bundles.
2026-06-04 15:36:16 +02:00
Marc-André Bombeck fc6a55b91c fix(medications): give the wizard stepper jump buttons a 44px touch target
The first / last jump buttons rendered at 32px, below the minimum touch
target. Extend the hit area with an invisible inset pseudo-element so the
tappable region is 44px while the visual button stays 32px; the existing
aria-labels are unchanged.
2026-06-04 15:36:12 +02:00
Marc-André Bombeck 3c7d12f9c1 fix(medications): replace the list loading spinner with card skeletons
The bare centred spinner reserved a 32-unit-tall box that the resolved
card grid then displaced, jumping the layout. Render a grid of card
skeletons built on the real Card shell + shared Skeleton primitive so the
loading state matches the loaded footprint.
2026-06-04 15:36:12 +02:00
Marc-André Bombeck 0c3c80a4f6 fix(medications): show a pending spinner on the intake-edit save button
The intake-edit dialog already tracks a busy state and sets aria-busy, but
the Save button gave no visual feedback while the PUT was in flight, unlike
the wizard and quick-add. Render the same inline spinner during the
mutation.
2026-06-04 15:36:12 +02:00
Marc-André Bombeck 8434af40cb fix(medications): surface failed dose logging and add an undo affordance
A failed take/skip POST cleared the spinner without any feedback, so the
user believed the dose was logged when it was not. Show an error toast on
a non-ok response or a thrown request, and never show the success
confirmation in that case.

On a successful take/skip, attach an Undo action to the toast that
soft-deletes the just-recorded intake via the per-event delete route, so
a misclick no longer needs a trip through the intake history to correct.
2026-06-04 15:36:12 +02:00
Marc-André Bombeck 0458c9aea8 feat(whoop): ingest body measurement — weight, max-HR, profile height
The body-measurement endpoint was fetched by a function nobody called, so WHOOP
weight, max heart rate, and height never reached the database despite being
documented in the mapping table.

Add sync-body.ts and wire it into the per-user sync loop (and, through it, the
backfill):

- weight_kilogram → a WEIGHT Measurement (source = WHOOP) on the stable
  externalId whoop:body:weight with overwrite semantics. A body measurement is
  a single self-reported profile value, not a time series, so a re-sync updates
  the same row in place rather than accumulating one row per poll. measuredAt is
  the fetch time; the source-priority picker ranks a real scale above WHOOP.
- max_heart_rate → WhoopConnection.maxHeartRate (a profile constant).
- height_meter → User.heightCm (m→cm), written ONLY when the user has no height
  yet — a user-set height is never overwritten and height is never minted as a
  Measurement.

Every write is field-by-field and idempotent across reruns. Document the body
mapping and the watch-only blood-pressure stance in mapping.md.
2026-06-04 15:36:06 +02:00
Marc-André Bombeck c8e8499472 fix(whoop): degrade a per-resource 403 gracefully instead of parking the connection
A 403 on a single WHOOP collection endpoint was classified reauth_required,
which parked the whole connection at error_reauth and short-circuited every
future sync. A lower-tier user missing one gated data class would brick the
entire integration.

Treat a per-resource collection 403 as a soft skip of that data class: log,
return 0, and continue so sibling resources still sync. Reserve connection-wide
reauth for a 401 (token rejected) and for a 403 on the token-refresh / profile
path, which run outside the per-resource catch blocks and remain unchanged.

Add isCollectionForbidden as the shared gate and cover both branches in tests.
2026-06-04 15:36:06 +02:00
Marc-André BombeckandClaude Opus 4.8 dd3ac3510f test(ssrf): lock the pre-existing moodLog and ntfy host pins
Extend the SSRF pin inventory to also assert the requirePublicHost pins
already present on the user/operator-supplied moodLog push/sync webhooks
and the ntfy sender, so the full set of overridable-host egress points
is CI-guarded and a future edit that drops one reds here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:26:19 +02:00
Marc-André BombeckandClaude Opus 4.8 ba4349eda0 refactor(db): extract the P2002 predicate into one shared helper
The Prisma unique-constraint (P2002) narrowing predicate had been
hand-copied into five call sites. Fold them into a single isP2002 in
src/lib/prisma-errors.ts and import it everywhere (the two step-drain
sites keep their local isUniqueConstraintViolation name via an import
alias). Behaviour-identical; one place to chase a future P2002-semantics
change instead of five.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:26:07 +02:00
Marc-André BombeckandClaude Opus 4.8 9c39afa352 fix(settings): per-fact pending state on coach-memory forget
A single shared forgetOne mutation meant forgetOne.isPending disabled
every per-fact forget button during one delete, so the whole list read
as frozen. Track the in-flight fact id via the mutation variables so
only the row being deleted disables and shows the spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:26:00 +02:00
Marc-André BombeckandClaude Opus 4.8 2763a64b83 fix(dashboard): hide iOS-pin-only widgets from the web settings list
The eight v1.10 additive HealthKit signals (cardio recovery, six-minute
walk, stair ascent/descent speed, breathing disturbances, wrist
temperature, falls, walking steadiness) are writable dashboard widget
ids so the native client's Home-pin PUT validates them, but the web
dashboard renders strip tiles via hardcoded per-id blocks and has no
render path for them. Left as-is, the web Settings -> Dashboard list
would offer eight toggles that do nothing on web.

Name them in a new IOS_PIN_ONLY_WIDGET_IDS set (writable-but-not-
web-rendered) and filter that set out of the rendered settings list, so
the toggles never appear on web while the ids stay accepted by the
widgets PUT enum for the native client. Add tests asserting the eight
remain writable (catalogue membership) yet are absent from the web list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:25:55 +02:00
Marc-André BombeckandClaude Opus 4.8 2994954138 test+a11y: fix mocks/expectations for source-priority + dashboard ids; pair coach-memory spinner with motion-reduce
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:12:18 +02:00
Marc-André BombeckandClaude Opus 4.8 07c444f938 fix(rollups): treat a concurrent same-partition P2002 as benign
The synchronous DAY rollup hook carries no pg-boss singleton, so two
measurement writes landing on the same (user, type, day) can interleave
their delete-then-insert and the losing createMany trips the per-source
unique index. The peer rewrote the identical source-collapsed partition
— the aggregate is a pure function of the same underlying rows — so the
rollup is already correct. Catch the P2002 in the single-chunk
transactional path, annotate it, and return rather than failing the
recompute. Any other Prisma error still bubbles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:01:54 +02:00
Marc-André BombeckandClaude Opus 4.8 7923d75afe chore: clear v1.11.2 QA-low cleanups
- Drop the unused `now` field from the coach `ExtractOpts` type; fact
  extraction never read it (conversation-summary keeps its own `now`).
- Correct the no-ladder tiebreak docstring in measurement-read: the
  collapse picks the alphabetically smallest source name, matching the
  live-SQL `ORDER BY … source` tiebreak, not max count.
- Replace the hand-inlined canonical-source subquery in the summaries
  rollup-path narrows query with `canonicalMeasurementsFrom(rank, "90 days")`
  for live/rollup parity with its siblings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:59:38 +02:00
Marc-André BombeckandClaude Opus 4.8 0949fadfb0 feat(dashboard): make the v1.10 additive metrics pinnable to Home
Add eight widget ids (cardio recovery, six-minute walk, stair ascent/descent speed, breathing disturbances, wrist temperature, falls, walking steadiness) to the dashboard widget catalogue + default layout (hidden by default) so they can be pinned via the layout PUT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:53:38 +02:00
Marc-André BombeckandClaude Opus 4.8 1f3b0ff5b0 perf(insights): thread source-priority once through graded-series readers
Load the source-priority blob once and pass it into both
readBestGranularityRollups calls. Completes the B3 fast-path threading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:38:25 +02:00
Marc-André BombeckandClaude Opus 4.8 b132abc789 perf(analytics): thread source-priority once through bp-in-target fast path
Load the source-priority blob once and pass it into both BP readers
instead of letting each re-query the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:34:13 +02:00
Marc-André BombeckandClaude Opus 4.8 b45ca232fa perf(analytics): thread source-priority once through correlations fast path
Load the source-priority blob once and pass it into the three
readRollupBuckets calls instead of letting each re-query the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:27:56 +02:00
Marc-André BombeckandClaude Opus 4.8 38ef581e7a test(security): inventory guard for the SSRF requirePublicHost pins
Source-grep guard asserting the openai/anthropic/geo outbound sites keep
the unconditional DNS-rebinding pin and the local AI client keeps its
conditional LAN escape hatch — so a future edit can't silently re-open
the SSRF surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:23:43 +02:00
Marc-André BombeckandClaude Opus 4.8 bd61374351 fix(security): pin DNS-rebinding check on user/operator outbound hosts
Add requirePublicHost to the three outbound safeFetch sites whose host is
user- or operator-controlled and lacked the connect-time pin: the OpenAI
and Anthropic clients (BYO base-URL override) and the IP-geo lookup
(IP_GEO_LOOKUP_URL env). A base URL resolving to a private/metadata
address is now rejected. The LOCAL AI client intentionally stays
conditional (LAN escape hatch). Redirect-follow is already closed by the
safeFetch defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:20:23 +02:00
Marc-André BombeckandClaude Opus 4.8 fcb41b8780 feat(coach): settings UI to review and forget what the Coach remembers
Add a 'What the Coach remembers' settings section: lists the user's
durable facts grouped by category with a learned-when stamp, forgets a
single fact or clears all (behind a confirm), and notes the rolling
conversation summary. Consumes the v1.11.1 CoachFact routes via the
query-key factory; gated on the Coach feature; localised in all six
locales. Closes the API-only gap from v1.11.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:16:26 +02:00
Marc-André BombeckandClaude Opus 4.8 7030266220 fix(coach): drop unused version constants, cap stored summary length
Remove the two never-wired *_PROMPT_VERSION exports the dead-code gate
flagged, and consume the summary-length target as a real backstop that
caps the stored rolling summary so a misbehaving provider can't bloat
the encrypted column or the next prompt's history window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:54:18 +02:00
Marc-André BombeckandClaude Opus 4.8 9b2b137a6c fix(coach,rollups): QA reconcile — decrypt isolation + collapse parity
- conversation-summary: fault-isolate the per-message decrypt in the
  fold loop so one undecryptable historic turn (e.g. mid key-rotation)
  no longer permanently stalls a conversation's summary refresh.
- source collapse: unify the no-ladder tiebreak on alphabetically
  smallest source across the JS reader and the all-time rollup SQL, so a
  ranked vital whose day carries only off-ladder sources resolves to the
  same canonical source on the warm and the live-SQL read paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:38:44 +02:00
Marc-André BombeckandClaude Opus 4.8 8e2e697370 feat(coach): facts management routes (list + forget)
Add the GDPR/trust surface for the durable Coach facts: GET lists the
caller's active facts decrypted, DELETE /[id] soft-deletes one, and a
bulk DELETE clears all — every verb apiHandler + requireAuth +
requireAssistantSurface("coach") + ownership-scoped, with an unknown or
cross-user id collapsing to an idempotent no-op that never leaks
existence. Registered in the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:29:18 +02:00
Marc-André BombeckandClaude Opus 4.8 0a5ba025c8 feat(coach): inject durable facts into the snapshot memory block
buildCoachMemoryBlock gains a third fault-isolated sub-source — the
top-N durable facts — folded under snapshot.memory so they ride the
existing lowest-priority registration and are shed first under the
char budget. Add an EN + DE system-prompt paragraph instructing the
Coach to use facts as the user's own stated, descriptive context —
never as a diagnosis, never invented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:22:44 +02:00
Marc-André BombeckandClaude Opus 4.8 9a0ea1be56 feat(coach): inject the rolling summary + enqueue memory refresh
fetchConversationWithMessages now returns the decrypted rolling summary
(fail-closed → null), and buildHistoryWindow substitutes it for the dead
placeholder so a long conversation keeps memory of its elided turns.
Once a conversation grows past the turn cap the chat turn fires a
fire-and-forget coach-memory-refresh so the next long turn is fresh,
without adding a provider round-trip to the interactive reply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:21:19 +02:00
Marc-André BombeckandClaude Opus 4.8 7e29595a71 feat(coach): wire the combined memory-refresh worker queue
Add the coach-memory-refresh pg-boss queue: a fire-and-forget enqueue
contract (coach-memory-shared) and a worker pipeline that runs the
conversation-summary refresh then the fact extraction for one long
conversation, each fault-isolated and budget-gated. Register the queue
in allQueues + a boss.work handler in the worker, with a source-grep
inventory guard so the queue can never ship unregistered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:18:52 +02:00
Marc-André BombeckandClaude Opus 4.8 5591858f16 feat(coach): conversation-summary + durable-fact compute modules
Add the two background generators behind Coach long-term memory, both
reusing the budget-aware provider chain (runStatusCompletion) with a
local fallback and encrypting their output at rest:

- conversation-summary: folds the turns elided past the history window
  into a rolling natural-language summary, merging into any prior
  summary; short-circuits cheaply until a conversation grows long.
- facts: extracts durable, descriptive personal facts (preferences,
  stated conditions, goals, constraints, context), gated by a Zod
  schema + de-dup + per-user cap; buildCoachFactsBlock ranks the top
  facts for snapshot injection. Never diagnostic, never numeric.

Both are pure/injectable for unit testing and annotate counts only,
never content. Wiring into the queue, route, and snapshot follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:16:26 +02:00
Marc-André BombeckandClaude Opus 4.8 01d3afcdaf feat(coach): scaffold conversation-summary + facts storage
Extract the AES-256-GCM Bytes codec from persistence into a shared
bytes-codec module so the conversation summary and the new personal-fact
store reuse one ArrayBuffer-backed implementation. Add the CoachFact
table (migration 0116) for durable, user-scoped facts the Coach extracts
from conversations — encrypted fact text, plain category/confidence/
source for rank-without-decrypt, soft-delete for a reversible forget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:11:19 +02:00
Marc-André BombeckandClaude Opus 4.8 7c494b60cb feat(rollups): collapse overlapping sources across all read paths
Complete the source-aware rollup read path: the slim summaries slice
(rollup + live), the comprehensive aggregator (both paths, including the
dailyByType correlation feed), and the /api/measurements route (rollup
read + the live date_trunc fallback) now resolve overlapping sources to
the source-priority ladder's canonical reading per (type, day) before
aggregating. A dual-source vital (e.g. WHOOP + Apple Watch resting heart
rate) surfaces one canonical series instead of a blend or a doubled
count; cumulative metrics keep summing per canonical source.

The collapse runs in SQL where the aggregate must stay a single
round-trip (buildSourceRankCase emits a per-user ladder-rank CASE;
canonicalMeasurementsFrom restricts a live read to canonical-source rows)
and in application code (collapseRollupRowsBySource) for the row readers,
both driven by the same resolved ladders so live and rollup agree.

Also widens the enum-literal SQL whitelist to accept digits so VO2_MAX
no longer throws on the typed write-hook recompute or the rank CASE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:05:47 +02:00
Marc-André BombeckandClaude Opus 4.8 d06eec6ace feat(rollups): collapse sources in the DAY + WMY rollup readers
readRollupBuckets and the WEEK/MONTH/YEAR readers now select the source
column and run collapseRollupRowsBySource before composing, so every
rollup consumer (dashboard sparkline, fast-paths, derived metrics, range
deltas) sees one canonical row per bucket. loadUserSourcePriority loads
the ladder blob once; readBestGranularityRollups threads it through each
granularity probe so the per-floor walk never re-queries the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:37:25 +02:00
Marc-André BombeckandClaude Opus 4.8 cddade3b4f feat(rollups): add source-priority collapse helper for read path
collapseRollupRowsBySource resolves the per-source rollup rows minted by
the source-aware writer down to one row per bucket, using the user's
source-priority ladder (the same ordering the raw-row picker uses). A
metricKeyForType superset maps every overlapping non-cumulative vital
(RHR, HRV-SDNN, SpO2, body/skin temperature, respiratory rate, weight,
body fat, blood pressure, pulse, VO2max, recovery) plus the cumulative
keys onto a SourcePriorityMetricKey. Unlisted sources fall back to the
highest-count row so a bucket never doubles or goes dark.

Wired into the rollup readers in the following commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:33:30 +02:00