Commit Graph
4099 Commits
Author SHA1 Message Date
Marc-André Bombeck 2b3ff0e58f fix(daily-loop): thread the overdue medication id into the digest's Log-dose link
The Today digest's dose-window rail item linked to the bare /medications
list even though the overdue medication's id was already resolved
server-side alongside its name — a tap landed the user on a generic list
they then had to scan themselves.

buildMedsTodayBlock now carries nextDueMedicationId; the digest's
"Log dose" action links to /medications?highlight=<id> when the id is
known, falling back to the bare list for an older cached block. The
medications page reads the highlight param, scrolls the matching card
into view, and rings it briefly (mirrors the vault's duplicate-upload
highlight) in both the card grid and the table view. Regenerates
docs/api/openapi.yaml for the new medsToday field.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 844b24c7ba fix(daily-loop): invalidate the Today digest on measurement/mood/water writes
Manual measurement, mood, and water writes never carried queryKeys.dailyDigest()
in their invalidation bundles — only medication intake did (v1.29.1). The digest
query also runs with refetchOnMount/WindowFocus off, so a write from the values
list, the mood list, or the hydration quick-add sheet left the Today hero's
score and rail items stale until a hard reload.

Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds
a shared refetchInactiveDailyReads() helper (mirrors the existing medication
seam) that forces the dashboard snapshot and digest to refetch even while
unmounted. Every measurement/mood/water write call site now runs it alongside
its existing *DependentKeys bundle.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck bf1c052797 feat(insights): add a metric catalog for discoverability
Metrics with zero data stay hidden from the dashboard and Insights nav
by design — that keeps the daily views uncluttered but leaves no
reachable surface telling a sparse-device-kit account which
capabilities exist at all. Add /insights/catalog: every metric domain
grouped by category, each row showing whether the account has data
for it and what would supply it, with a connect CTA into
settings/integrations when absent and device-sourceable.

Reuses the existing tab-strip gating (hasMetricData over the shared
analytics/comprehensive/workouts/nutrients reads) and the per-metric
empty-state copy already shown on each sub-page, so names, sources,
and presence can't drift from what's shown elsewhere. Adds one quiet,
always-visible pill at the tail of the Insights tab strip as the entry
point — no new loud nav destination.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck 5f253f7d09 fix(insights): make the nutrients pill reachable once the module is on
The nutrients pill required BOTH the opt-in module on AND at least one
NutrientIntakeDay row — but the in-context enable CTA lives on
/insights/nutrients itself, so a user who had just turned the module on
(or wanted to) had no way to reach the page that would let them. Drop
the row-count floor for this one metric: the pill now gates on the
module toggle alone, matching the deliberate opt-in the user already
made. The page itself is unchanged and still degrades from "enable" to
"no rows yet" to the three-card spine.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck d124a3239d fix(insights): stop the pulse chart from faking a continuous shape, add honest error states
Intraday pulse rendered sparse spot readings as a smooth interpolated
curve across hours-wide gaps, implying continuous monitoring the data
never had. The chart now grids one point per bucket, breaks the area
over any missing bucket (connectNulls={false}), and discloses coverage
("based on N readings across M hours") whenever that produces a visible
break.

Four surfaces also collapsed a failed fetch into the honest empty/no-
data state instead of surfacing the error: the Today hero, the
medication efficacy tab (previously stuck on the loading skeleton
forever on error), the intraday pulse chart, and the recent-workouts
dashboard tile. All four now branch on isError and render
QueryErrorCard with a retry action; the tile's "loading…" text line is
replaced with row-shaped skeletons.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck fb73c97125 fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new
tab, which returned a raw JSON 400 for every brand-new account (Withings
credentials are per-user BYO with no env fallback, and the wizard never
collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical
raw-JSON-on-no-creds pattern on their own /connect routes, while every other
failure branch already redirected with a toast reason. All six now redirect
to /settings/integrations?<provider>=error&reason=nocreds instead, and the
wizard's Withings card checks the integrations-status envelope first: unless
credentials are already configured, the CTA reads "Set up in Settings" and
links straight to the credentials form rather than launching a doomed OAuth
handshake.

Also surfaces the BYO-developer-app requirement, which was invisible until a
user was deep inside a Settings card. The source-picker step now says
up front that device sources need a free developer app on the user's own
account; each of the six provider cards gets a compact 3-step guide with the
exact callback URL to register, shown until the user has saved their own
credentials.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 4f7420905a fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.

Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8902d3985c fix(measurements,insights): source-collapse + timezone parity across multi-source surfaces
Four data-correctness fixes in the same class as the workouts-list
ordering bug (05f80ad37):

- GET /api/measurements groupBy=day: the raw-row scan ordered oldest
  first and sliced to `limit` before bucketing, so a long history never
  surfaced its recent days. Reads newest-first now. The per-day sum
  also ran over every source unfiltered — an Apple Health + Withings
  write for the same day doubled the total. Routes through
  pickCanonicalSourceRows first, matching the rollup tile's collapse.

- PR detection's cumulative-day picker summed every row on a calendar
  day regardless of source (false "record" days) and bucketed on the
  UTC calendar day instead of the user's timezone. Rewritten to
  source-collapse before summing and bucket on the user's IANA zone,
  reading through the worker's injected Prisma client so the existing
  dependency-injection contract for tests stays intact.

- The correlation-discovery matrix (the insights route, the per-metric
  assessment card, and the Coach's get_correlations tool — three
  independent copies of the same fetch) averaged cumulative types
  (steps, daylight) and sleep across mismatched grains: per-sample
  chunk means blended with drained daily totals, and sleep averaged
  per-stage segment durations instead of summing a night's total.
  Hoisted the grain resolution into one shared
  buildMeasurementDailySeries helper (source-collapsed day-sum for
  cumulative types, per-night reconstruction for sleep, mean
  unchanged for spot vitals) so all three surfaces agree.

Regression tests cover newest-first ordering, cross-source collapse
matching the rollup, PR no-double-count, and the shared grain helper.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 30a0528ad0 fix(analytics): resting-pulse pill drops the diagnosis-shaped verdict
classifyPulse() labelled resting pulse < 60 "Bradycardia" and > 120
"Tachycardia", the latter painted destructive-red. The interpretation layer
already frames the same signal calmly for the same range ("below the
standard resting range (common in trained hearts)", neutral valence) — a
trained user at 55 bpm resting read a diagnosis-shaped verdict on one
surface and a calm contextual band on the other.

Rename the categories to "Below typical range" / "Above typical range"
(matching the interpretation layer's own wording) and step the high band
down from destructive/danger to warning/amber — still flagged, never a
verdict noun. Neither category paints the pill red anymore.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck cffebd78db fix(mental-health): crisis card no longer sends AT/CH users DE-only numbers
The PHQ-9 item-9 crisis card routed every "de" locale to the Germany-only
TelefonSeelsorge freephone lines. Those numbers don't connect from Austria
or Switzerland, so a Swiss or Austrian user in crisis was shown dead numbers
first on the one surface where that costs the most.

Add dedicated Austria (Telefonseelsorge 142) and Switzerland (Die
Dargebotene Hand 143) resource sets. `User.locale` only ever stores a bare
short code today, so an unqualified "de" now resolves to a combined
DE/AT/CH set (112 first, then every country's own line) instead of
defaulting to Germany; a region-qualified tag resolves to the exact country
when one is available.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8c0b200a5b fix(doctor-report): footer states the user's own timezone, not Berlin
The clinician PDF footer read "Timezone: Europe/Berlin" for every user
regardless of their actual timezone — a maintainer-anchored default leaking
into a document handed to a clinician, who then reads every timestamp on
the page against the wrong basis for a non-Berlin operator.

`userTz` already carries the resolved per-user zone into the formatters;
thread the same resolution (valid IANA name, else the Berlin fallback) into
the footer string as {timezone} instead of the literal text.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 0518c39050 fix(illness): glucose safety-floor push honours the user's display unit
The hypoglycemia/hyperglycemia escalation push hardcoded mg/dL in all six
locale strings and interpolated the raw mg/dL reading regardless of the
user's glucose display preference. A mmol/L user hitting the confirmed-hypo
alarm read "70 mg/dL (65 mg/dL)" — the wrong unit at the single most
safety-critical moment.

Resolve User.glucoseUnit in the safety-floor write hook and convert both the
reading and the floor threshold before handing them to the notification
copy. The bundle strings now take {threshold} {unit} / {value} {unit}
instead of a baked-in "70 mg/dL" / "54 mg/dL".
2026-07-17 23:12:12 +02:00
Marc-André Bombeck a08e4378a0 Merge pull request #521 from MBombeck/feat/efficacy-scale-history
feat(medications): scale efficacy y-axis and reach older doses
2026-07-17 22:41:36 +02:00
Marc-André Bombeck 0a08b166cf Merge pull request #523 from MBombeck/feat/document-auto-summary
feat(documents): persist an auto summary for uploaded documents
2026-07-17 22:41:30 +02:00
Marc-André Bombeck ba362f1d10 Merge pull request #522 from MBombeck/release/v1.29.5
release/v1.29.5
v1.29.5
2026-07-17 17:47:16 +02:00
Marc-André Bombeck 5cff7473c9 feat(documents): persist an auto summary for uploaded documents
When the auto-AI-read opt-in is on, a freshly uploaded document is summarised
once in the background and the encrypted summary is stored and shown on the
document detail view.

- A background step runs after upload alongside the existing content-index and
  thumbnail steps. It generates a short (3-4 sentence) plain-language summary
  only when the user's documentsAutoAiRead opt-in is on (that flag is also the
  standing AI-egress consent), skips a document that already has one, and
  no-ops when no vision provider is configured. It reuses the same provider
  resolution, egress-consent re-assertion, and AI-budget reserve/reconcile
  helpers as the on-demand route; a provider error refunds the reservation and
  never fails the upload.
- The summary is stored encrypted (summaryEncrypted Bytes) with a
  summaryGeneratedAt timestamp on inbound_documents, registered for key
  rotation, and returned decrypted in the document detail response.
- The on-demand transient summary route is unchanged.

Gated by the same consent, budget, and module checks.
2026-07-17 17:46:28 +02:00
Marc-André Bombeck 85a05efb4d chore(release): v1.29.5 — OIDC redirect base + same-origin redirect hardening 2026-07-17 17:30:54 +02:00
Marc-André Bombeck d8e48a6304 fix(security): reject a same-origin next path that re-resolves protocol-relative
sanitizeSameOriginPath returned pathname+search+hash, so a crafted relative next like /..//evil.com passed the origin check but left a //evil.com pathname that re-resolves to a foreign origin when a consumer re-feeds it to new URL(result, base) or router.push. Re-verify the reconstructed path resolves on-origin; reject to / otherwise. Covers the OIDC callback and the password/passkey login redirect.
2026-07-17 17:30:04 +02:00
Marc-André Bombeck 60658be776 Merge pull request #519 from balajiv113/fix-url
fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
2026-07-17 17:26:57 +02:00
Marc-André Bombeck b11ecca8d2 feat(medications): scale efficacy y-axis and reach older doses
- Efficacy target chart: replace the implicit zero-based y-axis with a
  padded data-range domain so a small relative trend fills the plot
  instead of compressing into the top band.
- Add a "% since start" chart mode that reindexes the target series to a
  percentage of the start-anchored baseline; the absolute reference band
  is hidden in that mode.
- Intake history tab: toggle between the trailing 90-day dose ledger and
  the full paginated intake history so older doses stay reachable.
2026-07-17 17:17:54 +02:00
Marc-André Bombeck 43eddb63b0 Merge pull request #520 from MBombeck/release/v1.29.4
release/v1.29.4
v1.29.4
2026-07-17 17:09:49 +02:00
Balaji Vijayakumar 351cf7dd85 fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
Every browser-facing redirect in the OIDC login/callback routes was built
from req.url. Behind a reverse proxy that doesn't forward the original
host, that reflects the address the app process was addressed as inside
the container rather than the public domain, sending users to an
unreachable internal URL after login. Route every redirect through the
trusted, operator-set NEXT_PUBLIC_APP_URL instead, matching the
convention the Withings integration already uses.
2026-07-17 20:30:41 +05:30
Marc-André Bombeck 05f80ad378 fix(workouts): order the list newest-first; show the year on past-year dates
The canonical picker re-sorts its output to startedAt ASC for its rollup callers, so the list route sliced the oldest page and recent workouts never surfaced on a long history — re-establish newest-first after the pick. And a workout from a previous year now shows its year so it is not read as this year's.
2026-07-17 16:53:06 +02:00
Marc-André Bombeck 546c5c7d8f Merge pull request #518 from MBombeck/release/v1.29.3
release/v1.29.3
v1.29.3
2026-07-17 16:29:00 +02:00
Marc-André Bombeck c527cd5aa4 chore(release): v1.29.3 — hydration and micronutrients surface
Fluid intake and device-recorded vitamins/minerals get a home: a dashboard water tile with quick-add, and a Nutrients page under Insights showing hydration over the last month, caffeine, and the micronutrients with data — each against its reference intake as context. Manual and device-synced water coexist via a source-aware day-total key.
2026-07-17 16:13:41 +02:00
Marc-André Bombeck 16d142762a feat(insights): /insights/nutrients — hydration, caffeine, vitamins
New routed sub-page, gated on the opt-in nutrients module with an
in-context enable CTA (the module stays opt-in — the 2026-07-17
consent memo's reasoning holds; this just makes the toggle
discoverable instead of buried in Settings):

- Hydration card (hero): today's total, a muted EFSA reference-intake
  line (sex-resolved, omitted when the profile has no sex on file),
  a 30-day bar chart with a dashed reference line, and quick-add
  chips (+200/+300/+500 mL + custom + edit-today's-total) in a
  ResponsiveSheet, POSTing to /api/nutrients/water.
- Caffeine card: same anatomy, rendered only when the window has
  data; the EFSA safe-level reads as a ceiling, never "exceeded"
  colouring.
- Micronutrients card: rows only for the 24 vitamin/mineral codes
  with data in the window (catalog order), a muted "M of 24 tracked"
  footer, and one honest sentence that sparse logging isn't a
  deficiency. Zero rows collapse to a single EmptyState.

No ring, no attainment colour anywhere — the bar chart plus its
dashed reference line IS the context (UI-STANDARDS: values stay
foreground).

The bar chart is a new NutrientDailyBarChart, registered in the
shared chart-runtime barrel behind its own next/dynamic boundary so
recharts stays off the route's first-load JS.

Tab-strip wiring: nutrients joins the flat pill set (event/store
driven like medications/workouts — a lightweight 1-day probe read
drives the pill's data-availability gate, module gate on top).

~20 new i18n keys across en/de/es/fr/it/pl.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck b396b51d35 feat(dashboard): fluid-intake strip tile, nutrients-store-backed
Reuses the TrendCard strip-tile pattern (icon, neutral direction
sentiment, stale-days caption) for a new waterIntake widget, but
sources it from NutrientIntakeDay instead of introducing a parallel
MeasurementType. The dashboard snapshot builder reads the last 30
day-keys for nutrient="water" (summed across sources), folds them
into a synthetic NUTRIENT_WATER summary key alongside the real
MeasurementType summaries, and the existing module-gating machinery
(SUMMARY_TYPE_MODULE, WIDGET_MODULE_BY_ID) strips it when the opt-in
nutrients module is off — no new gating code path, just two new map
entries.

The tile links out to /insights/nutrients instead of a measurement
detail page. Widget catalogue grows 37 -> 38 ids.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck da2de015fe feat(nutrients): source-aware day totals; manual water write path
NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.

- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
  byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
  sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
  modes against the MANUAL row only, module-gated, rate-limited,
  idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
  nutrient (summed across sources) plus the EFSA reference resolved
  against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
  helper both new routes and the insights cards share.

docs/api/openapi.yaml regenerated for the two new routes.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck bef3b9dd3f Merge pull request #517 from MBombeck/release/v1.29.2
release/v1.29.2
v1.29.2
2026-07-17 14:46:26 +02:00
Marc-André Bombeck 2b5fb617ba chore(release): v1.29.2 — WHOOP workout sport, intraday history, Today dismiss, vitals info-tip
WHOOP workouts map to canonical sports (bike rides show as cycling) with a history backfill and a richer cross-source merge; the intraday heart-rate view gains a day navigator with 90-day dense retention and an hourly fallback for older days; milestone/ECG/tension cards can be dismissed from Today; vitals context moves into an (i) popover and the VO2max heading no longer clips; Oura cycle phases feed the cycle tracker where available.
2026-07-17 14:35:06 +02:00
Marc-André Bombeck 7445925d58 fix(insights): move vitals method context into an (i) popover; unclip VO2max
The always-on "typical range = median ± MAD" caption was dropped from
the vitals tiles because it repeated across every baseline tile — but
that same inline caption, still rendered unconditionally on the four
derived re-frame tiles (cardio fitness, vascular age, HRV, BMI), was
squeezing the header row hard enough to clip the heading next to it:
measured on the cardio-fitness tile, the "VO2 max" title's rendered
width collapsed from 64px to 15px with the caption in the row, cutting
it down to "V.". Confirmed and re-measured with a Playwright pass over
the actual TileHeader/SparklineDeltaTile markup before touching the CSS.

Replace the inline caption with a small (i) trigger (VitalsInfoTip) on
the tile heading's trailing edge — a click-opened popover carrying the
same method/caveat text plus the cited standard, matching the existing
click-to-open info-tip pattern used elsewhere (touch-friendly, unlike a
hover tooltip). Restore it on the VITALS_BASELINE tiles too, since a
compact icon repeated across tiles doesn't clutter the way the full
sentence did. The heading now keeps its full width regardless of how
long the method copy runs.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 270f33edb2 feat(daily): let the Today rail dismiss milestone / ECG / tension cards
The worth-a-look rail mixed actionable items (an overdue dose, a broken
sync) with observational ones (a milestone, a new ECG pointer, a tension
window) that have nothing to act on beyond "seen it" — there was no way
to clear them short of them expiring on their own.

Add server-side dismissal scoped to the three observational kinds only:
a new DismissedPriorityItem table (userId + itemKey, composite-unique),
a POST /api/daily/digest/dismiss route, and a deterministic itemKey the
digest builder stamps per kind (metric + reach day for a milestone, the
recording's own timestamp for an ECG, the local day + part of day for a
tension window) so a dismissal never bleeds into a later day's instance.
The actionable kinds never carry an itemKey, so the dismiss affordance
and the endpoint are both structurally unreachable for them — an
itemKey not namespaced under a dismissible kind 422s before any lookup.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 4f198ec25f feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.

Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 3aa11b0531 feat(oura): feed Cycle Insights phases into the cycle tracker
Pull Oura's daily_cycle_phases collection (undocumented in the published
OpenAPI spec, but confirmed live and auth-gated identically to every other
daily_* collection) on the existing hourly Oura poll. A luteal→follicular
transition, or a literal "menstrual" phase day, writes a single-day
CycleDayLog(flow=LIGHT, source=OURA) — the same conservative "logged but
unspecified intensity" marker the HealthKit unspecified-flow mapping uses.

Manual entries always win: the write only ever creates a day that has no
existing CycleDayLog row at all, regardless of source, and never updates a
row it did not create itself. No MenstrualCycle span is synthesised, mirroring
the Apple Health importer precedent. A 403/404 on the endpoint (the expected
outcome for most connections — Oura's reproductive-health resources are all
absent from its public spec) is swallowed without touching the rest of the
Oura connection's sync status.

No migration: reuses the existing CycleDayLog.source column and the OURA
value already present on MeasurementSource.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck b3d586066a fix(workouts): backfill richer fields on the canonical-picker winner
pickCanonicalWorkout() picked one whole row per cluster by the source
ladder and dropped the rest, so a higher-priority twin with a null HR
(e.g. a Withings summary) discarded a lower-priority twin's real reading
(e.g. WHOOP's HR strap) instead of keeping it. The ladder winner is now
the base row, backfilled with avgHeartRate, maxHeartRate,
totalEnergyKcal, totalDistanceM, and elevationM from the ladder-highest
member that actually has each field — filling gaps, never overwriting a
value the base row already carries.

The base row also adopts a specific sport type from a cluster member
when its own is generic ("other" / unrecognised). Reaching that case
needs clustering to accept a generic↔specific sport pairing within the
proximity window, alongside the existing exact-match rule — two
different SPECIFIC sports (Walking vs Running) still never cluster, so
the core invariant holds.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 86e92f38ca fix(db): backfill non-canonical WHOOP workout sport_type values
Existing WHOOP Workout rows still carry whatever the pre-fix write path
stored — a whoop_sport_<id> placeholder or WHOOP's raw sport_name — so
the sport-map fix only corrects new syncs. This one-shot UPDATE
re-derives every existing WHOOP row's sport_type from the same table
sport-map.ts uses, generated directly from that table so the historical
data and the live mapper can't drift apart. Unmatched values default to
"other", matching the mapper's fallback. Idempotent: the WHERE clause
skips rows already on a canonical value, so a re-run touches zero rows.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 43884569d3 fix(whoop): map sport_id/sport_name to the canonical WorkoutSportType
WHOOP was the only workout integration that never normalised its sport,
unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw
sport_name or a literal whoop_sport_<id> placeholder straight into
Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling
workouts never rendered as cycling on /insights/workouts because nothing
in the UI could map the non-canonical string to an icon or label.

sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public
Workout-object reference and mirrors the Fitbit/Google mapper shape:
sport_id is preferred (retired past 2025-09-01 but still exact when
present), sport_name is normalised before the fallback lookup, and
anything unrecognised resolves to "other" — never a placeholder. The raw
WHOOP fields stay in Workout.metadata for traceability.

Adds a shared table-driven test that checks Fitbit, Google Health, and
WHOOP sport mappers together so the three integrations can't silently
drift out of parity on common sports.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 63cdd709af Merge pull request #516 from MBombeck/release/v1.29.1
release/v1.29.1
v1.29.1
2026-07-17 11:25:00 +02:00
Marc-André Bombeck 99d80f8512 chore(release): v1.29.1 — polish and fixes from the daily view
Today refreshes right after a dose; a calmer Today hero; the Coach names the attached document; a fresh install starts with tracking on; the vitals explainer line is removed; the illness journal gains an editable end and a settled course line; the morning refresh now triggers from every sleep source and records each skip.
2026-07-17 11:12:30 +02:00
Marc-André BombeckandClaude Opus 4.8 2da6a192a7 feat(polish): default tracking modules on; illness backdate + course fixes
A clean install now defaults every tracking module on (weight, blood
pressure, glucose, sleep, mood, medications, labs, documents, insights,
mental wellbeing, cycle, illness, environment, workouts) so the app is
usable without enabling each surface by hand. Only the two channels that
open an egress or attack surface stay opt-in — the remote MCP endpoint and
the micronutrient sync; the document AI-read and the daily-briefing push
remain separately gated and off. The change touches defaults only:
accounts with a saved preference resolve exactly as before, the new default
applies solely where no preference exists.

Drop the "typical range = median ± MAD" method caption from the vitals
baseline tiles — it repeated across every tile and read as out of place at
the top of the vitals surface. The dedicated bands and composite scores
keep their method copy.

Illness episodes: the capture sheet now carries an editable end date
alongside the start, so a whole past episode can be recorded after the
fact and an open-ended course can be resolved (or re-opened) later. The
end is bounded to on-or-after the start.

Fix the per-episode "how it progressed" card rendering an empty body when
the engine returned a valid result with no notable findings — it now shows
a calm, honest line instead of looking as though nothing was recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:02:57 +02:00
Marc-André Bombeck f6e6593bcb fix(coach): name the attached document(s) in the fenced banner; keep the attach menu within its popover
The fenced-conversation banner said a generic "N documents attached", which
gave no hint what a thread was about when returning to it later. It now names
the attached document(s): one document shows its title, two show both, three or
more show the first two plus an "and N more" tail. Reuses the existing
"Chatting about: …" doc-scope phrasing and the attachment DTO titles; the title
truncates within the pill so a long name never breaks the banner. Falls back to
a plain count only when no title has resolved yet.

Also constrain the composer attach menu: a long localised label — e.g. "Neues
Dokument hochladen" — could run past the popover edge. The menu items now shrink
and truncate within the menu width, mirroring the vault picker items.

Adds a "chattingAboutMore" string across all six locales.
2026-07-17 11:02:47 +02:00
Marc-André BombeckandClaude Opus 4.8 65f90160c7 fix(daily): refresh the Today digest after a dose, drop the hero ring cluster
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.

Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.

Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:00:22 +02:00
Marc-André Bombeck f8c0ea5120 Merge pull request #515 from MBombeck/release/v1.29.0
v1.29.0 — A daily health companion
v1.29.0
2026-07-17 09:56:24 +02:00
Marc-André Bombeck af4cad46fe fix(insights): load the intraday pulse chart through the shared chart runtime
The pulse page imported the intraday area chart directly, so its
`from "recharts"` import minted a second recharts fingerprint chunk and
pushed total client JS past its budget. Route it through the shared
chart-runtime barrel via the same single next/dynamic boundary every other
chart uses, so recharts stays one shared chunk.
2026-07-17 09:46:09 +02:00
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 2d9237fbad chore(release): v1.29.0 — a daily health companion
The capstone of the daily-value milestone. On top of the Today view, the daily
loop (coach check-in, morning freshness, daily note) and the documents/coach
work already shipped, this completes the picture:
- ECG woven into the insights narrative (device-attributed only, never the
  app's own reading; a new recording surfaces gently in Today).
- Intraday heart rate as the shape of the day, with a careful elevated-at-rest
  tension note (never during activity, never a verdict).
- Calm milestones — states reached, not streaks to keep.
- The cohesion layer: the chosen health rings return to the Today hero, each
  surface carries its metric's calm colour identity, and a quiet reached-moment
  for a milestone — one design throughout.

No new data collected; every daily surface reads the overnight digest. No
migration in this release.
2026-07-17 09:10:56 +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