Commit Graph
1357 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.8 c21131e26e feat(cycle): second-audit feature, QoL, and wiring close
Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +02:00
Marc-André BombeckandClaude Opus 4.8 3375bbabac fix(cycle): second-audit safety, privacy, and write-path hardening
Address the second-round audit (server cluster):

- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
  stronger "not a contraceptive method" disclaimer — wire the existing
  strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
  an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
  batch instead of re-reading the profile per day; hoist the bulk cycle
  attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
  is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
  in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
  metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:15:22 +02:00
Marc-André BombeckandClaude Opus 4.8 31a14ce7bf feat(cycle): premium cycle ring, BBT chart, and full read-state coverage
Finish the cycle web vertical's signature surfaces:

- Rebuild the cycle wheel to the wellness-ring premium signature — a
  clockwise clock-wipe that draws the phase arcs on from the 12-o'clock
  day-0 origin, a phase-tinted bloom on the active arc, the shared
  specular sheen, a day-number count-up, and a soft current-day marker
  halo. All on the wellness-tile surface, gated once per session and
  zeroed under prefers-reduced-motion.
- Add the basal-body-temperature chart to the predictions tab: the
  current-cycle BBT series with phase-tinted points, fertility signs in
  the tooltip, and an estimated-ovulation reference line (suppressed in
  raw-chart mode). Surface basalBodyTempC + ovulation-test + mucus on the
  calendar day DTO so the chart reads from the existing calendar load.
- Cover the read states the prediction/disclaimer surfaces were missing:
  pass the disclaimer to the predictions panel, render it on the calendar
  tab, add per-tab error + retry, and gate the settings spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:09:00 +02:00
Marc-André BombeckandClaude Opus 4.8 4b10bdc5da feat(cycle): single-day read endpoint, symptom severity, and a richer log-day sheet
Add `GET /api/cycle/day-logs?date=YYYY-MM-DD` returning the full day-log DTO
(or null) for one tz-anchored day so the web log-day sheet pre-fills instead
of opening blank — a blank re-log no longer nulls untouched or HealthKit-sourced
fields. Delete resolves the row id from the same read.

Persist an optional 1-4 Likert severity per symptom link (migration
0133_v1150_cycle_symptom_severity), carried through the shared symptom-link
writer and surfaced in the day-log DTO.

Sheet now captures the previously missing fields — spotting between periods,
pregnancy/progesterone home-test results, contraceptive kind — plus per-symptom
severity chips, a one-tap end-period boundary, and a Delete action. The wheel
ring marker sits at the 12-o'clock origin again (drop the double rotation), the
cycle view goes two-column on desktop, and the empty ring carries a first-period
call to action. New copy is localized across all six locales; the prediction and
fertility surfaces keep their disclaimers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:39:28 +02:00
Marc-André BombeckandClaude Opus 4.8 824dcbb01a perf(cycle): scope the reminder cron to the in-window cohort and harden the period write
Push the cycle-reminder cron's gating into the query instead of loading
every CyclePrediction row each tick: filter on the user's
cycleTrackingEnabled / predictionEnabled relation and restrict to rows
whose nextPeriodStart falls inside a tz-generous [today-grace, today+lead]
string window. The per-tick cost now scales with the in-window cohort, not
the whole table; the exact per-user-timezone window still runs in JS.

Make the period-boundary shortcut transactional and idempotent: wrap the
close-prior + open-new (or stamp-current) cycle mutations in a single
$transaction so two concurrent taps cannot double-close a prior cycle or
compute a length against a stale read, and add withIdempotency so an exact
key replay is a no-op. The boundary day-log now ranks the incoming flow
against any existing same-day entry and never downgrades a richer flow.

Decode sync cursors additively: a live beta v1 token keeps its
measurement / mood / intake watermarks and simply fresh-scans the two
domains added in v2 (cycleDays, cycles) rather than forcing a full
re-init. Correct the stale "no live consumer" comment.

Thread the user's timezone through the Coach cycle snapshot so "today",
day-of-cycle and days-until-period derive from the user's zone — matching
the calendar and insights routes — instead of wall-clock Berlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:17:07 +02:00
Marc-André BombeckandClaude Opus 4.8 15bece9849 fix(cycle): scope symptothermal to the current cycle, unify luteal clamp, widen fertile + crosstab coverage
The symptothermal layer ran against the full day-log history: detectTempShift
returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day
across all cycles, so a multi-cycle user with historical BBT confirmed a
months-old ovulation and the predicted next-period start landed in the past.
Scope the temperature/mucus detectors to the current cycle window
[lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift,
and reject any confirmation whose next-start is already in the past.

Resolve luteal length through a single clamp. The predictor clamped to [10,16]
while the calendar adapter and phase mapper used the raw stored value, so the
ovulation dot and the OVULATORY band could land on different days for an
out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar,
buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod
bound to the same [10,16] range.

Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the
algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE.

Bound the day-log reads on the calendar and insights routes to the rendered span
plus the symptothermal lookback instead of the full history; cycle-length stats
already run off the MenstrualCycle rows.

Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR
and per-group day floors. Mood is read from MoodEntry and injected as a synthetic
channel since it is not a measurement row.

Add multi-cycle, luteal-clamp and fertile-window fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:06:48 +02:00
Marc-André BombeckandClaude Opus 4.8 ac13dd28ed fix(cycle): honour the sensitive-data encryption toggle + harden the write path
- When the sensitive-category encryption preference is on (the default),
  encrypt the intent-revealing day-log fields (sexual activity, protection,
  pregnancy/progesterone tests, contraception) into an encrypted envelope and
  leave the queryable columns null, decrypting on read. The toggle now does
  what it says rather than storing those fields in plaintext.
- Make the day-log write a partial merge so a re-log only overwrites the
  fields it carries, never nulling untouched ones (no more data loss when the
  same day is logged again).
- Make the upsert collision-safe: a manual entry and an imported entry on the
  same date adopt one canonical row instead of throwing, and the single POST
  returns a clean conflict rather than a 500; one bad date no longer aborts a
  whole import.
- Compare the decrypted note, not the ciphertext, so an unchanged re-log is a
  true no-op and stops needless re-sync.
- Hoist cycle attribution out of the per-entry loop; map bulk failures to
  stable reason codes; audit the import goal change; mask the event name in
  discreet reminder pushes; add a one-tap hard-delete purge for all cycle data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:49:23 +02:00
Marc-André BombeckandClaude Opus 4.8 3e398295a3 feat(account): accept a third gender value
Add "OTHER" to the accepted gender values (the column is a free String, so no
migration) and surface it in the account selector. This lets a non-binary or
trans user record their gender and opt into the cycle-tracking surface through
the per-user enable flag, rather than being gated out by a binary field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:40:01 +02:00
Marc-André BombeckandClaude Opus 4.8 55f40df0ac refactor(cycle): wire the phase-correlation card into the insights tab
The cycle insights tab rendered an empty placeholder while the phase-contrast
card + its `/api/cycle/insights` feed sat unused. Wire them together: a
`useCycleInsights` read drives the headline finding + the luteal-vs-follicular
crosstab on the insights tab, matching the mood-factor crosstab styling. Drops
the placeholder card + a duplicate disclaimer component in favour of the ones
already in use, and removes the unused recency/phase-result/label exports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:26:13 +02:00
Marc-André BombeckandClaude Opus 4.8 e984ada5f1 feat(cycle): import Apple Health reproductive data into cycle, add cycle export
Promote the reproductive HealthKit category types out of the
Apple-Health-import privacy-hold list and route them into cycle day-logs
instead of measurements, and surface cycle data on the three export
surfaces.

Import promotion:
- A new HealthKit -> cycle mapping module is the single source of truth for
  how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality,
  OvulationTestResult, SexualActivity, PregnancyTestResult,
  ProgesteroneTestResult, Contraceptive and the symptom category types fold
  into CycleDayLog fields / seeded symptom keys. The XML importer
  accumulates same-day reproductive samples and writes one canonical
  day-log per day with source APPLE_HEALTH and a synthetic per-day
  externalId for idempotent re-import. The fold is gated on cycle tracking
  being enabled, so a non-cycle export never provisions cycle rows. A
  contraceptive method nudges the profile goal toward avoiding pregnancy
  only when the goal is still on its default. Basal body temperature stays
  a measurement and mirrors onto the day-log. Pregnancy/Lactation status
  and the pregnancy-bleeding types stay deferred; the cycle awareness types
  remain server-derived.

Export:
- FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period
  length (64698-4) and the current phase as opt-in reproductive-health
  observations grouped into their own document section.
- The doctor PDF gains an opt-in cycle section (recent cycles, average
  length and variability, current phase), privacy-default off like mood.
- The full-backup export, the weekly backup worker and the off-host DR
  snapshot include the cycle tables, and the admin restore re-creates them;
  the day-log note round-trips as ciphertext and is never decrypted onto a
  report surface.

Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit
+ integration coverage for the mapping, the import routing, the FHIR
observations and the backup round-trip. OpenAPI regenerated. Six-locale
strings added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:54 +02:00
Marc-André BombeckandClaude Opus 4.8 7333d8e40f feat(cycle): add the cycle-tracking web vertical
Build the web UI for the cycle-tracking category against the existing
`/api/cycle/*` routes: a month calendar + circular phase wheel, a fast
log-day sheet, a predictions panel, phase-insight cards, and a single-page
settings form, all under a gated `/cycle` tab.

- Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the
  wellness-ring aesthetic with calm per-phase hues) showing the current
  phase + day-of-cycle, alongside a month grid where logged period days show
  a filled pip, the predicted next period renders as a ranged band (never a
  single date), the fertile window shows only when the goal allows it
  (the API suppresses it server-side otherwise), and ovulation + per-day
  symptoms carry markers. Reads `GET /api/cycle/calendar`.
- Log-day sheet: one-tap period start, flow / symptom / ovulation-test /
  mucus chips, basal-temperature, intercourse + protection, and a free-text
  note. Optimistic write to the day-log + period routes; invalidates the
  cycle query keys.
- Predictions panel: next-period range with a confidence pill, goal-gated
  fertile window, a still-learning state for fewer than three cycles or
  raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer
  (reusing the drug-level-estimate disclaimer pattern).
- Insights cards render whatever phase-correlation cards the API surfaces,
  with a graceful empty-state until the feed lands.
- Settings: a single dynamic form where the inclusive goal selector drives
  the copy (conception framing only for trying-to-conceive), plus typical-
  length priors, raw-chart mode, sensitive-category encryption, discreet
  notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`.
- Nav: a Cycle entry in the sidebar and the mobile More hub, gated on
  `cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise.

Add the cycle query keys + dependent-key bundle to the factory, full copy
across all six locales, and dedicated per-theme phase hues. Plain
second-person voice, WCAG 2.1 AA, no new dependencies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:06:20 +02:00
Marc-André BombeckandClaude Opus 4.8 6fdbb8d889 feat(cycle): predicted-period notifications + reproductive-data privacy docs
Add two cycle reminders through the existing notification cascade and the
honest self-hosting privacy framing for reproductive-health data.

Notifications:
- New event types CYCLE_PERIOD_SOON (fires ~2 days before the predicted
  next-period start, from the cached prediction) and CYCLE_PERIOD_CONFIRM
  (a gentle "did your period start?" on/after the predicted start while no
  period is logged, within a short grace window). Both default OFF and are
  gated again on cycleTrackingEnabled — opt in twice for a sensitive surface.
- A 15-minute pg-boss cron mirrors the mood-reminder worker: it scans users
  with cycle tracking enabled + predictionEnabled, computes who is in-window
  from the cached forecast, and dispatches one push per event per local day.
  Idempotency anchors on the push_attempts ledger (no new table). The queue
  is declared, registered in allQueues, scheduled, and wired to a boss.work
  handler, with a source-grep guard so it cannot ship unregistered.
- Honours notificationPrefs.cycle.clientManaged (skip the server push when
  the device owns local reminders, with a suppressed annotation, mirroring
  the medication path) and CycleProfile.discreetNotifications (generic
  "HealthLog reminder" body so no cycle event is named on the lock screen).
- Every attempt is recorded in push_attempts via the unchanged dispatcher.

Privacy / disclaimer:
- docs/self-hosting/reproductive-health-data.md — the "no cloud vendor to
  subpoena" framing with the honest residual-threat caveat (your own host,
  provider, backups, and devices remain part of the surface), citing the
  2021 FTC settlement and 2025 verdict as public record only.
- docs/security/cycle-data-threat-model.md — a compact "protects against /
  does not protect against" note, plus README pointers.
- CyclePredictionDisclaimer component (reusing the GLP-1 estimate-disclaimer
  pattern) for every fertility/prediction surface: descriptive, not a
  contraceptive method, not medical advice, never a safe-to-have-unprotected
  -sex claim. 6-locale copy under cycle.disclaimer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:00:42 +02:00
Marc-André BombeckandClaude Opus 4.8 77a652a47c feat(cycle): cycle-phase correlation insight + Coach phase dimension
Add the menstrual-cycle phase as a channel in the existing FDR-guarded
correlation machinery, the same way rated mood factors were folded in —
no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same
day floors and q = 0.10 gate.

Two complementary mechanisms, both confined to a gender-gated surface:
- a categorical luteal-vs-follicular contrast per outcome metric (resting
  heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature),
  surfacing one headline finding (resting-heart-rate-by-phase, falling
  back to HRV); and
- the continuous phase ordinal folded into the lagged-Pearson matrix.

Phase never reaches the general correlations route — the temperature
channels held out of the general matrix are un-blocked only here, behind
the cycle gate, so a reproductive inference can never leak onto a
non-gated surface. Every row shows n, effect size, p and q; nothing is
fabricated on thin data and nothing is framed as causal.

Surface it through a new gated GET /api/cycle/insights and a drop-in
card that mirrors the mood-factor crosstab.

Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle,
the next predicted event (period range + confidence + method, fertile
window goal-gated), and the headline phase finding — present only for a
cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground
rule keeps the Coach descriptive: never contraception-grade, never a
"safe day" claim.

OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added;
copy localised across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:59:02 +02:00
Marc-André BombeckandClaude Opus 4.8 5f485ca780 feat(cycle): add cycle-tracking API surface + offline sync
Add the `/api/cycle/*` capture, calendar, history, and settings routes
plus the offline-sync delta extension for the v1.15 cycle-tracking
feature, all against the locked iOS contract.

- Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile
  (migration 0132). A null toggle derives from gender (FEMALE → on).
  `isCycleEnabled()` is the single gate resolver; every `/api/cycle/*`
  route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved
  flag surfaces additively on GET /api/auth/me.
- Routes (apiHandler + Zod + envelope + gate + annotate + auditLog):
  POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent,
  capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/
  skipped), POST period boundary, GET calendar (runs the deterministic
  engine, debounced stale-while-revalidate cache, goal-gated fertile
  window), GET cycles (history + median/MAD stats), GET profile,
  DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge).
- Day-log note encrypts at rest (notesEncrypted); intent fields stay
  queryable plaintext. Upsert keys on (userId, source, externalId) when
  externalId present, else (userId, date).
- Offline sync: extend GET /api/sync/changes + the cursor with the
  `cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays
  tombstones key on externalId else id; cycles on id.
- OpenAPI: register every route + DTO; regenerate the committed spec.
- i18n: cycle prediction disclaimer across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:57:12 +02:00
Marc-André BombeckandClaude Opus 4.8 ea0d7681e2 feat(cycle): add deterministic prediction + phase engine
Pure, DB-free cycle prediction and phase-assignment engine under
src/lib/cycle/. The math mirrors the offline-parity spec exactly so the
server and the native client compute byte-identical forecasts from the
same canonical inputs.

- day-math: noon-UTC anchored YYYY-MM-DD arithmetic (dayDiff, addDays,
  round-half-up) that stays stable across timezones and DST transitions.
- prediction: robust median+MAD cycle-length estimator over a trailing
  12-cycle window with 3-MAD outlier exclusion and missed-log detection;
  period-length estimator; one-sigma Low/High date band widened by
  logging sparsity; luteal back-calculated ovulation + six-day fertile
  window; the CALENDAR -> symptothermal -> temperature-trend method
  ladder (sensiplan 3-over-6 + mucus-peak re-implemented from the
  published method) with documented blend precedence; a multiplicative
  confidence scalar shrinking with length variance, low cycle count, and
  sparse logging.
- phase: day-of-cycle -> MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL boundary
  mapping with menstrual precedence on overlap.
- 48 unit tests covering the regular, irregular, cold-start, outlier,
  symptothermal, temperature-trend, and every phase-boundary case; all
  assertions are timezone-independent and pin exact dates + confidence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:29:52 +02:00
Marc-André BombeckandClaude Opus 4.8 a5946e6209 feat(cycle): add cycle-tracking schema foundation
Introduce the persistence layer for cycle tracking: eight enums
(CycleTrackingGoal, FlowLevel, OvulationTest, CervicalMucus,
HomeTestResult, ContraceptiveKind, CyclePhase, PredictionMethod) and
seven models.

CycleProfile is a 1:1 per-user settings row (lazily upserted on first
access via getOrCreateCycleProfile). MenstrualCycle and CycleDayLog are
the capture rows, carrying the MoodEntry soft-delete + syncVersion shape
so they ride the sync delta feed; CycleDayLog repeats the MoodEntry
dual-unique — the canonical (userId, date) key plus the NULL-distinct
(userId, source, externalId) key for HealthKit re-ingest. Free-text
notes encrypt at rest; queryable physiology (flow, BBT, OPK, mucus,
dates) stays plaintext for the rollup and correlation tier. The symptom
taxonomy (CycleSymptomCategory / CycleSymptom / CycleSymptomLink) clones
the mood-tag trio and seeds fifteen symptoms across three categories
with cycle.symptom.* i18n keys across all six locales. CyclePrediction
caches one forecast per user, regenerated debounced.

Migrations 0128-0130 are additive; the testcontainer integration suite
applies the full history from scratch and passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:19:32 +02:00
Marc-André BombeckandClaude Opus 4.8 189202f4c6 fix(insights,medications): reconcile review findings on the v1.14.0 surfaces
- Medication cycle status: re-hydrate `nextDueAt` (it arrives as an ISO string
  over the wire) before the Berlin day-bucketing, so "next dose in N days" no
  longer drifts by a day near midnight or off Berlin. Covered by a new
  string-input test.
- Wellness rings: drive the conic sheen off a fixed white so it always lightens
  (a near-black light-mode foreground under overlay was darkening the tile into
  a grey band); give the ring a transform transition so it scales smoothly with
  the tile lift instead of snapping; cap the green-band glow at a few breaths
  instead of an infinite drop-shadow repaint; deepen the light-mode stress and
  recovery end-stops for non-text contrast; track the sm/md column count to the
  active-score count so two or four scores never leave an empty cell.
- Mood factor crosstab: bound the snapshot's cross-metric read to the 365-day
  window and exclude soft-deleted rows, so the factor deltas the model cites
  match the visible board over the same canonical row set (no recency-biased
  truncation, no soft-delete skew); thread the user's source priority so the
  per-day canonical pick agrees with the screen; render the delta neutrally
  rather than green/red, since a higher vital is good for steps but bad for
  resting heart rate and the board is observational.
- Log-intake: surface an error when a pinned slot can't resolve to an instant
  instead of silently logging the dose through the unscheduled path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:22:29 +02:00
Marc-André BombeckandClaude Opus 4.8 dfda2a1c7e feat(insights): correlate rated mood factors against vitals
Rated mood factors (work / sleep-quality / stress, scored per entry) now
participate in the cross-metric correlation surface. Until now their score
series was captured but read by zero analytics.

Two paths, both on the existing FDR-controlled engine:

- A factor x vital crosstab on the mood insights page and the mood
  snapshot: for each factor the user scores, split its days low vs high by
  the factor's own median, then run the same Welch test + day floors +
  Benjamini-Hochberg correction the tag crosstab uses. Surfaces lines like
  "sleep runs ~38 min below baseline on the days you rated work low"
  (n-gated, q <= 0.10, never fabricated on thin data).
- Factor channels (FACTOR:<key>) fold into the period-narrative discovery
  matrix as both lag source and lag target, so the drivers the coach and
  narrative reference can cite a factor relationship.

An inverse factor (higher rating = worse day) gets the documented sign-flip
once, at the series boundary, so "up" always reads as a better day and the
low/high split and phrasing stay honest. Strings land in all six locales;
the surface carries the standing "association, not cause" caption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:58:40 +02:00
Marc-André BombeckandClaude Opus 4.8 30a7437f4d feat(insights): per-score assessment text + shared metric signal block
Add an additive, non-breaking assessment field to GET /api/insights/derived:
{ text, source, updatedAt } | null, keyed to the requested metric id for the
five per-score ids (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE,
STRESS_SCORE). The field is null when the metric is not one of those or when
its status is not "ok". A deterministic text always fills it — composed from
the score's own contributors, so provider-less accounts and the demo are never
empty — and warmer provider prose overrides it once cached, warmed out of band
so the GET never blocks on a model round-trip. de/en, following the v1.12.12
deterministic-narrative model. The batch grid read returns assessment: null;
the detail sheets use the single-metric route.

Introduce a shared buildMetricSignal() helper: a token-lean, signal-rich
descriptor (current, own baseline, signed delta, spread, normal-swing verdict,
normal-range placement, contributors) computed server-side so the model states
the comparison instead of re-deriving it from raw buckets. Thread it into the
generic per-metric assessment snapshot and lead the base prompt from it. Add a
PROMPT_VERSION constant to the base system prompt so prompt changes are
traceable.

Regenerate docs/api/openapi.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:57:39 +02:00
Marc-André Bombeck 016f866965 fix(insights): health-score BP pillar from all-time window + surface ACTIVITY_STEPS in Insights 2026-06-05 22:47:46 +02:00
Marc-André Bombeck 874794e0e4 fix(insights): sleep average via deduped reconstruction + night-reconstruction guard 2026-06-05 22:09:06 +02:00
Marc-André Bombeck a55816bf58 fix(medications): retrospective rolling-cadence compliance + currentCycle descriptor 2026-06-05 22:09:06 +02:00
Marc-André BombeckandClaude Opus 4.8 f1329b24e1 chore(release): v1.13.0 — per-user custom mood tags + catalogue hide
Let a user shape their own mood-tag set on top of the global catalogue:
create custom tags (label encrypted at rest, icon from a fixed allow-list),
and hide built-in tags per user. Custom tags live in `mood_tags` with a
non-null `user_id` and a `custom:<uuid>` key; `GET /api/mood/tags` returns
the effective per-user set (catalogue − hidden + own customs) with new
`custom` + `label` fields, and `?include=hidden` surfaces hidden catalogue
tags for the management screen. The write path resolves a `custom:` key only
against the caller's own rows — a foreign custom key is dropped, never linked.
Delete soft-deactivates by default (history intact); `?purge=true` removes
the row and its links. BINARY-only for v1.

New: POST/PATCH/DELETE /api/mood/tags/custom[/:key], PUT /api/mood/tags/:key/hidden,
migration 0127 (user_id + label_encrypted columns, mood_tag_hidden table,
`custom` category). Integration setup snapshots + restores the seeded catalogue
because the new mood_tags→users FK makes TRUNCATE users CASCADE wipe it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:50:05 +02:00
Marc-André BombeckandClaude Opus 4.8 f25b6aa2dc chore(release): v1.12.12 — deterministic fallback so the period retrospective is never empty
When no AI provider is configured (or before the first AI summary is written),
the period-in-review card stayed empty. Compose a concise, factual, strictly
non-causal summary from the same structured digest the AI prompt is built from
— ranked metric changes vs the prior period, vitals that moved outside their
typical range, and an honest count of statistical associations — and persist it
with a `deterministic` provider marker so a later AI warm replaces it in place.
The read route now warms regardless of provider; the read-never-blocks posture
is unchanged. Fills the no-key demo's retrospective too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:10:08 +02:00
Marc-André BombeckandClaude Opus 4.8 6871e8ea77 fix(medications): stop defaulted-now intake from auto-taking a far slot
A slot-less "taken now" write (no client scheduledFor) defaulted its
instant to now/takenAt and snapped across the wide ±halfGap window onto
the nearest scheduled slot. For a 07:00 / 19:00 med a midday write
captured the 07:00 morning slot and stamped it taken — a phantom morning
dose. The web fix only threaded the displayed slot on the card; any
client posting takenAt-only (iOS bulk/sync) still tripped it server-side.

Thread an instantIsExplicit flag through resolveSlotInstantForWrite into
the canonical resolver. An explicit scheduledFor keeps the full ±halfGap
snap unchanged. A defaulted-now write snaps only inside the tight
dose-grace window and otherwise resolves to null (PRN), so it records a
standalone taken-now row instead of back-filling a far slot. Both intake
call sites pass scheduledFor !== undefined; takenAt alone is not explicit.

Add resolver unit coverage for midday defaulted-now writes (07:00/19:00 →
null at 11:00/12:00/13:00, explicit 07:00 still snaps) and a DB-backed
regression that a takenAt-only midday write leaves the 07:00 slot untaken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:59:02 +02:00
Marc-André BombeckandClaude Opus 4.8 c59290ddc6 test(mood): align bulk catalog tests with the revised factor set
The retired RATED factors leave no seeded factor with a scale narrower
than the 1..5 rating envelope, so the per-tag out-of-scale "skipped" path
is no longer reachable through the catalog (it stays covered by the
`resolveRatedFactors` unit tests). Update the bulk integration tests to
use surviving factors and assert the envelope rejection, and swap a
deactivated tag for an active one in the tag-key round-trip. Refresh the
stale per-tag-scale comment in the rated-factor schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:28:47 +02:00
Marc-André BombeckandClaude Opus 4.8 b3a626d83c docs: add navigational READMEs to key code directories
Add a README to src, src/lib, src/app, src/components, prisma, scripts,
and e2e so a technical visitor browsing the tree gets oriented: what
lives in each directory, the most important files, the local
conventions, and pointers to related dirs and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:26:34 +02:00
Marc-André Bombeck fbdf831cb0 fix(insights): strip MOOD from the period-narrative measurement query
MOOD is mood-entry backed, not a MeasurementType; it was filtered out of
DISCOVERY_BEHAVIOURS but not DISCOVERY_OUTCOMES, so it leaked into the
measurement.findMany type filter and threw PrismaClientValidationError,
failing the period-narrative warm job.
2026-06-05 15:24:17 +02:00
Marc-André Bombeck a3692a62f8 ai: align the remaining Anthropic default to the current Sonnet 2026-06-05 14:47:49 +02:00
Marc-André BombeckandClaude Opus 4.8 47255a93b5 fix(ai): align provider default model fallbacks
The BYOK Anthropic client fell back to the retired claude-3-5-sonnet-latest;
match the chain-runner default (claude-sonnet-4-6). The admin ai-settings GET
displayed a gpt-4o-mini fallback that disagreed with the actual provider
default (gpt-4o); align the display string so the surfaced model matches what
the provider would use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:46:10 +02:00
Marc-André BombeckandClaude Opus 4.8 dabedd2edc feat(whoop): map cycle average/max HR and sleep disturbance count
mapCycle now emits AVERAGE_HEART_RATE and MAX_HEART_RATE from the cycle
score's average_heart_rate / max_heart_rate — both were already on the typed
WhoopCycle interface but dropped on the floor. mapSleep now reads
stage_summary.disturbance_count (added to the WhoopSleep interface) and emits
SLEEP_DISTURBANCE_COUNT. All three guard nulls / unscored records and carry
distinct field tags (avg_hr / max_hr / disturbances) so they don't collide
with the existing strain / energy / stage rows under the shared resource
externalId. Update mapping.md to document the three and note the cycle HR
pair were previously fetched-but-dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:46:05 +02:00
Marc-André BombeckandClaude Opus 4.8 ef188e0503 feat(measurements): add average/max heart rate + sleep disturbance types
Add three MeasurementType values for WHOOP fields a coverage audit found
uncaptured: AVERAGE_HEART_RATE and MAX_HEART_RATE (the cycle score's
whole-day aggregates, kept distinct from spot PULSE / RESTING_HEART_RATE /
WALKING_HEART_RATE_AVERAGE) and SLEEP_DISTURBANCE_COUNT (the per-night
disturbance tally). Units: bpm / bpm / count.

Wiring mirrors the existing per-type surfaces, traced from VO2_MAX and the
v1.11.0 WHOOP-native score classes:

  - canonical enum + unit + plausibility range in
    src/lib/validations/measurement.ts (every ingest / analytics / rollup
    path derives from measurementTypeEnum.options, so the mean-based rollup
    tier picks the new types up automatically).
  - Prisma enum + migration 0125 (ALTER TYPE ADD VALUE IF NOT EXISTS,
    non-transactional, idempotent — same pattern as 0111).
  - category overlay (cardiovascular for the HR pair, sleep for the count).
  - list-view label key / Lucide icon / chart-colour.
  - i18n label key in all six locales.
  - chart-token allowlist + orphan-enum stripper.
  - PersonalRecord direction (HR pair null — no clean goal axis, like
    WALKING_HEART_RATE_AVERAGE; disturbance count MIN, like
    BREATHING_DISTURBANCES).
  - enum-coverage drift guard (count 60 -> 63, doctor-PDF exclusions for the
    device-derived signals) + apple-health HK-counterpart exclusion (these
    are WHOOP-only; HealthKit ships no first-class daily-aggregate HR
    quantity nor a disturbance tally).

The types render on the measurement list + Insights chart surfaces; like the
v1.11.0 WHOOP-native scores they get no dedicated Insights sub-page (the
sub-page map stays curated) and no LOINC / FHIR mapping (not clinical-grade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:45:58 +02:00
Marc-André BombeckandClaude Opus 4.8 b6126db754 feat(insights): extend the anti-repetition diversity context to the headline cards
The v1.12.1 diversity block (variety lead, data-strength line, steady-run
repetition signal, FDR-screened cross-metric relations) reached only the
generic archetype cards. The seven hand-tuned headline cards — the ones users
see most — ran at temperature 0.3 with only the previous-context block, so they
stayed prone to repetitive openers and forced filler steps. Thread the same
assessmentContextBlock through the specialized get*UserPrompt builders and
raise their temperature to 0.45 to match the archetypes.

Soften the unconditional "close with one step" across the specialized sections
and the activity / body-composition / sleep archetypes to the base prompt's
conditional form — close with a step only when the finding implies one,
otherwise affirm and name one thing to watch — so a steady metric no longer
manufactures a filler step. EN/DE parity preserved; guard computeSteadyRun
against an absent graded slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:28 +02:00
Marc-André BombeckandClaude Opus 4.8 941947399a perf(insights): bound the medication-intake reads to the rendered window
general-status and blood-pressure-status read the full medication-intake
history with no time floor, scanning every row for accounts with years of
data. Both only consume the trailing ~365 days (the adherence fold and the
continuity series / 7-30 day compliance gate), so add a scheduledFor floor at
now - 365d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:28 +02:00
Marc-André BombeckandClaude Opus 4.8 005f4ba2be feat(insights): wire curated medical references into the live insight paths
The reference-grounding builder (buildSystemPromptWithReferences) and its
SOURCES list shipped to no one: both live paths called the plain system prompt,
so the model was asked to cite referenceId values it was never shown — and the
parser rejects invented ids, so the citation machinery the schema and UI
footnote already support stayed dark. Wire the builder into both consumers,
passing the metric sections the snapshot actually carries (a weight-only run
never lists BP guidance). Apply the fenced-JSON parse fallback here too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:28 +02:00
Marc-André BombeckandClaude Opus 4.8 e6ef9f2a3e fix(insights): tolerate fenced JSON from providers without a JSON mode
Anthropic and the local provider do not send a JSON mode, so a compliant model
still wraps its { "summary": … } reply in a code fence or prefixes a sentence,
which broke JSON.parse and surfaced the raw fenced string as the assessment.
Add a shared stripJsonFences helper (drop the fence, narrow to the first { …
last }) and apply it as a parse fallback in parseSummaryFromContent. No-op on
already-clean JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:28 +02:00
Marc-André BombeckandClaude Opus 4.8 0193dbc1ee refactor(insights): strip release markers from the model-facing prompt
The insights ground-rules carried changelog tokens ("v1.4.16 phase B8", "W4d")
inside the prompt strings the model reads — token waste and inconsistent with
the project voice. Move the rule content to clean numbered rules; the markers
belong in code comments, not shipped prompt prose. Update the test pins to the
cleaned rule headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:28 +02:00
Marc-André BombeckandClaude Opus 4.8 9a07a27001 fix(insights): default to current full-size AI models for the headline feature
The OpenAI BYOK + admin-shared fallback defaulted to a small model and
Anthropic to a 2024 Sonnet, so a user on the operator's shared key got the
weakest model for the Insights assessments. Bump the OpenAI default to gpt-4o
and the Anthropic default to claude-sonnet-4-6; both stay overridable by user
config. Sync the provider docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:28 +02:00
Marc-André BombeckandClaude Opus 4.8 437a627a66 perf(dashboard): reuse the probed coverage map in the summaries slice
The snapshot builder probed rollup coverage up front to gate the thick
extras phase, then called computeSummariesSlice which re-ran the
identical probeRollupCoverage query. Thread the already-probed map into
the slice as an optional argument so the snapshot's hot path issues the
probe once. The argument is optional with the internal probe as the
fallback, so every other caller is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André BombeckandClaude Opus 4.8 6de5de0284 perf(dashboard): serve the snapshot stale-while-revalidate on a measurement write
The dashboard snapshot lives in the analytics cache bucket, which had no
stale window, and every measurement write hard-evicted the whole
${userId}| prefix. A high-frequency iOS Apple-Health sync therefore
busted the snapshot into a cold rebuild on the next read all day long
(the same class as the v1.8.7 insight-cache eviction).

Give the analytics bucket a stale-while-revalidate window, read the
snapshot via cachedSwr, and switch the measurement-write invalidation
from a hard deleteByPrefix to markStaleByPrefix. The snapshot now serves
the prior value immediately while a single background recompute warms a
fresh one. Slim / thick / summary cells read via plain cached are
unaffected: a marked-stale entry has expiresAt === now, so their next
read is a clean miss and rebuilds fresh. Widget reorder keeps its hard
point-evict so a layout change paints synchronously; mood and medication
writes keep their hard analytics sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André BombeckandClaude Opus 4.8 ae468edca2 fix(idempotency): claim the key before running the side-effect
findCached returned null and the response was only persisted after the
handler completed, so two concurrent requests carrying the same
Idempotency-Key both passed the lookup and both executed the side-effect
(duplicate writes for the very clients idempotency protects).

Claim the key by inserting a pending sentinel row under the existing
(userId, key, method, path) unique constraint before the handler runs.
A racing request either finds the pending row or loses the insert race
and is refused with 409. The claim is promoted to the completed response
on a cachable result, released on a non-cachable result or a
secret-shaped body, and released on a handler throw. The pending row
carries a short claim TTL so a crashed handler self-heals and the
existing expiry-sweep cleanup still applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André BombeckandClaude Opus 4.8 037da560ad fix(compliance): compute medication streak in the user's timezone
The analytics compliance streak keyed days with host-local
getFullYear/getMonth/getDate against a timeline whose slot.day is minted
in the user's IANA zone, so the streak drifted off by a day whenever the
server clock's zone differed from the user's. Consolidate onto the
detail-page streaksFromTimeline helper threaded with the medication's
timezone so both call sites share one correct day-keyed walk.

Also fix the DST step in that helper: it stepped a fixed +24h while its
comment claimed 25h, which could skip a local day on the 23h
spring-forward boundary and run the window short. Snap each cursor to
the user-local midnight and advance +25h then re-snap, mirroring the
cadence expander, so every local calendar day is visited exactly once
across 23/24/25h boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André BombeckandClaude Opus 4.8 3b9f95e0b7 fix(measurements): fold late cumulative samples into existing day total
The per-sample cumulative drain strips already-collapsed stats: rows
from the day bucket, so a trailing watch sync that lands after a day's
total was minted leaves the bucket holding only the late samples. The
upsert update branch then replaced the full day total with the partial
late sum, and the contributing samples were already hard-deleted, making
the lost count unrecoverable.

Probe the existing total inside the write transaction and fold the late
sum into it (existing + late) instead of overwriting. The late samples
are genuine additional readings, unlike the legacy-step pass where the
existing total is HealthKit's own aggregate and folding would double-
count. Per-sample rows are still hard-deleted so a re-run cannot
re-accumulate them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André BombeckandClaude Opus 4.8 0995664893 feat(charts): add opt-in brush that reports per-type windowed stats
Add an optional Recharts <Brush> to the shared HealthChart, gated behind a
new `selectableDomain` prop. When set, the chart mounts the brush beneath
the plot and reports the Min/Max/Median/Mean over the brushed window per
type through `onDomainStats`. A range-tab change or a series refetch clears
or clamps the selection so the brush never indexes past the data.

The dashboard, trends-row, VO2 row, sleep, and rationale mounts do not pass
the prop, so they render unchanged — the feature is fully isolated behind
the flag.

`computeWindowStats` is a pure helper (no React/Recharts) so the chart can
summarise the array it already holds without a refetch and the maths is
unit-tested directly. The median uses the same linear-interpolated midpoint
as `summarize()` so a window and the full-range summary read on one
algorithm. `useChartDomainStats` lifts the per-type window into shared state
with a stable callback and a shallow guard against repeat windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:35:44 +02:00
Marc-André Bombeck ea30317723 fix(insights): use the reconstructed sleep night in status and null-out unestablished readiness contributors
The generic metric-status generator read SLEEP_DURATION row-per-stage and
fed the snapshot a single sleep STAGE (~16 min) as the current value, so the
sleep prose reported a fraction of an hour for a full night. Route the
SLEEP_DURATION status/snapshot through reconstructSleepNights — the same
per-night, source-deduped, midnight-aware time-asleep total the dashboard,
series, and targets surfaces use — so the snapshot's series, summary, and
latest value are all the night total in minutes (the registry unit and normal
range are already minutes). Gated to SLEEP_DURATION; no other metric's status
path changes.

The readiness wellness index could surface a contributor value of 0 for an
account that recorded resting heart rate / HRV / respiratory rate but had no
scorable baseline: a non-finite baseline center or today value fell through
clamp100 to a misleading 0 that reads as a real worst-case sub-score. Return
null instead so the client drops the contributor, and document the null-vs-0
contract: null means no usable signal (no baseline, no recent reading,
non-finite input); a finite value — including a genuine 0 — means the baseline
is established and the latest reading really deviates that far.

(cherry picked from commit 0422ac012765f30069a41f662c2b025197ed9a88)
2026-06-05 11:34:59 +02:00
Marc-André Bombeck 4ace1b7706 refactor(insights): unify the metric subpage template — target tile, order, stat strip, drop the range row
Make every metric subpage identical by construction in the shared
scaffold rather than per-page bespoke.

- Target tile matches the other cards: dark `bg-card` background and the
  `rounded-xl border p-4` spacing the assessment and stat-strip cards
  use, instead of the old translucent `bg-card/20` panel.
- Drop the 7T/30T/90T/1J range row and its "+X% vs previous" delta from
  every subpage. The chart owns its own window via its in-card range
  tabs, so the chart query is unaffected. Retire the now-dead
  `MetricRangeControls`, `MetricRangeDelta`, `TimeRangePills`,
  `useAnalyticsRange`, the `useInsightsRangePref` persisted-range hook,
  and `sentimentFromMetricDirection` along with their tests.
- Fix the spine order to intro -> chart -> target card -> assessment ->
  Min/Max/Median/Mittelwert stat strip. The stat strip moves to the foot
  of the spine, below the assessment. Remove the "Letzte Messung" card
  and the headline primary tile from subpages; retire
  `MetricLastMeasurementCard` and `MetricPrimaryTile`.
- Blood pressure gains the stat strip it was missing: two stacked
  systolic / diastolic strips, since a single-series strip would drop the
  diastolic half. `MetricStatStrip` takes an optional series caption.

Update the spine guard to assert the target card precedes the
assessment and that the retired range row and last-measurement card
appear on no subpage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 80b2a810314b4c0951c0d68c04bffc89003005d6)
2026-06-05 10:42:47 +02:00
Marc-André Bombeck 11f10df39e refactor(ai): modularise the insight prompt and unify the locale skeleton without output change
The per-metric assessment prompt (base-system) and the scope-hardened
insight system prompt (insight-generator) each carried EN and DE as two
parallel template literals kept in sync by hand — a standing drift risk
flagged in the AI-quality audit.

Model each prompt as one section skeleton: a single ordered list where
every section supplies its EN and DE fragment side by side, and a builder
joins the fragments with a blank line. The section order and prose are
unchanged; the DE calibration is preserved verbatim. A section edited in
one locale but not the other now shows up as an asymmetric edit in one
list instead of two literals silently diverging.

Pure refactor — every emitted prompt string is byte-identical for both
locales across every metric, surface and reference combination. A new
locale-skeleton-parity test pins the structure (each section carries both
fragments, composed prompt equals the joined fragments); the existing
grounding, filler-ban, forbidden-phrase, refusal and exact-phrasing guards
pass unchanged.

(cherry picked from commit 12cd718d6d602a48cafc14ad06d82282fdaacb27)
2026-06-05 08:45:36 +02:00
Marc-André Bombeck 1a478d3028 feat(whoop): native-scheme connect redirect and Bearer connect ticket
Two connect-in-app enhancements for the native client.

return_scheme: GET /api/whoop/connect accepts an optional custom scheme,
threaded through the server-side OAuth-state row so it survives the round
trip without ever riding in the URL or cookie. When a valid scheme is
present, the callback's final redirect targets <scheme>://whoop?whoop=
connected|error&reason=... so an in-app web session auto-completes on its
custom-scheme match; absent or invalid, the web settings redirect is
unchanged. Schemes validate against a strict allowlist (custom app scheme
only, ^[a-z][a-z0-9.+-]*$, http/https/javascript/data/file/vbscript
rejected, pinned to dev.healthlog.app). A rejected scheme falls back to
the web redirect and never reflects an arbitrary value.

Bearer connect ticket: a purely Bearer-authenticated client holds no web
session cookie, so it cannot start the handshake through the cookie-gated
connect route. POST /api/whoop/connect/ticket (Bearer) mints a one-time,
~60s, opaque ticket bound to the user; only its HMAC-SHA256 hash is stored
(the raw value never persists). GET /api/whoop/connect?ticket=<opaque>
accepts the ticket in lieu of a cookie, consumes it atomically (single-use,
reuse rejected), sets the nonce cookie, and 302s to WHOOP exactly as the
cookie path. Expired, consumed, or invalid tickets return a typed 401. The
two combine. The daily WHOOP OAuth-state cleanup also sweeps expired ticket
rows.

The ticket query param is added to the egress redaction denylist so the
opaque value never reaches logs or error reports.

Migration 0124 adds whoop_connect_tickets (id, user_id FK cascade,
token_hash unique, expires_at, consumed_at, created_at) and a nullable
return_scheme column on whoop_oauth_states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 221b362011d52dda314139e0be7cc195c47d016e)
2026-06-05 08:10:13 +02:00
Marc-André Bombeck 540abebf09 feat(ops): alarm on a TLS leaf SPKI change for pinned native clients
The native client SPKI-pins the server's TLS leaf certificate. When the
leaf auto-renews (GTS re-issues it roughly every 90 days) the leaf keypair
— and so the SPKI pin — changes, and a client shipped only the old pin
refuses to connect on the next renewal: a silent outage. Nothing
server-side signalled that a client pins the served leaf.

Add a pg-boss monitor that probes the served leaf every 6 hours, computes
its iOS SPKI pin (base64(sha256(DER subjectPublicKeyInfo)) via a raw TLS
socket + X509Certificate), and compares it against the operator's
known-good set in TLS_LEAF_SPKI_PINS (comma-separated for the dual-pin
renewal window). On a served pin outside the set it fails loud: a
tls.pin.leaf_changed wide-event annotation, a system.tls.pin_changed audit
row, and a high-priority SYSTEM_ALERT fanned out to admins through the
existing dispatcher idiom. Transient TLS/network failures annotate
tls.pin.probe_failed and never alarm.

The baseline is an env var, not a persisted last-seen row — a self-learning
baseline would silently adopt the first rotation and suppress the alarm the
pinned client needs; an unset baseline logs the served pin and warns rather
than auto-adopting. The runbook documents extraction, the re-pin + dual-pin
procedure (≥11 days before expiry, ship to TestFlight), and the assumption
that the probed host is the host the client connects to.

Wires the queue into the worker (allQueues + cron + worker), adds the
optional env to the manifest, compose whitelist, and .env example, and
covers the SPKI computation + change detection with a fixture-cert unit
test plus a queue-registration guard.

(cherry picked from commit cdb88cd77b88aec71ad2f21caf2b15c185dfc01e)
2026-06-05 08:10:13 +02:00
Marc-André Bombeck b64f48bce3 fix(mood): honour externalId for idempotent re-import on the native entry routes
Migration 0122 added MoodEntry.externalId plus the NULL-distinct
@@unique([userId, source, externalId]) and wired the moodLog webhook
and sync importer onto it, but the iOS-facing native routes never used
it: POST /api/mood-entries dropped externalId entirely (the schema did
not declare it) and POST /api/mood-entries/bulk accepted it in its Zod
schema yet parsed-and-ignored it, still upserting on the legacy
(userId, date, moodLoggedAt) key. The dedup index existed but nothing
on the native write path reached it, so the idempotent re-import the
client needs was not functional.

Declare externalId on createMoodEntrySchema (bounded to match the bulk
schema), persist it, and — when present — upsert the single-entry write
on (userId, source, externalId) so a re-post with the same id updates
the existing row in place instead of 409-ing or minting a duplicate.
When absent, keep the legacy first-write create and its 409-on-conflict
behaviour untouched. On the bulk path, branch the per-entry probe and
upsert onto the same compound key when an entry carries an externalId,
refreshing date and moodLoggedAt in the update so a re-zoned re-import
lands the corrected wall-clock on the same row; absent ids keep the
legacy wall-clock key. Both paths resolve source once so the dedup key
and the row write agree. Echo externalId back on the single response
(via the row) and on each bulk per-entry result so the client can map
server ids onto its local rows.

Cover the present-vs-absent upsert key, cross-user isolation, and the
echo with unit tests on both routes plus a real-Postgres integration
test proving a re-posted externalId resolves to one row updated in
place, a null externalId keeps wall-clock behaviour, and the same id
under two users stays isolated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6a65de22bc436daeeb73dd2a1cb612336c74a928)
2026-06-05 03:45:04 +02:00