Commit Graph
122 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.8 038be6ac57 chore(release): v1.12.11 — unify settings notice colours and callout style
Route every Settings callout, badge and status note through the semantic
colour tokens (success / warning / info / primary) instead of raw palette
classes, so the same meaning reads identically across cards and stays
legible in light mode. Add a single SettingsInfoTile primitive — icon +
tone + text on one fixed surface — and adopt it for the threshold-override
caution note, replacing a bespoke box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:42:18 +02:00
Marc-André BombeckandClaude Opus 4.8 4a88db6966 fix(ui): route status colours through AA-safe semantic tokens
Raw text-dracula-* utilities used as text fail WCAG AA contrast in light
mode (the Alucard light theme carries no override for the raw primitives).
Swap every text usage and its paired bg/border tint across the settings
integration/notification/sharing/api cards, the insights recommendation,
confidence, daily-briefing, health-score and coach-feedback surfaces, the
bug-report banners, the copy-confirmation icon and the chart comparison
caption to the theme-aware --success / --warning / --info / --destructive /
--dose-accent tokens, which both themes override to an AA-safe tone. Dark
mode is unchanged; decorative aria-hidden gradients and chart series fills
stay on the raw primitives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:07:56 +02:00
Marc-André Bombeck f25f7f3597 settings: full-width timezone select + match the excluded-sites card surface 2026-06-05 13:40:12 +02:00
Marc-André Bombeck 3ce0b69802 refactor(settings): unify card headers, integration verbs, test buttons, and tap targets
Holistic consistency pass over the settings and integration surfaces so the
same concept reads and behaves the same way everywhere.

- Card headers: migrate the four integration cards (Withings, WHOOP,
  Fitbit/Google Health, Mood Log) plus the profile, passkeys, avatar, about,
  api-endpoints, api-tokens, and sharing cards onto the shared
  SettingsCardHeader primitive. Extend the primitive with an inline title
  accessory slot (Fitbit tag + experimental badge) and a multi-line
  description slot (overlap / experimental / deprecation sub-notes), keeping
  the rendered result identical.
- Integration verbs: normalise Mood Log onto the canonical "Sync now" /
  "Sync all data" / "Synchronize" set used by the other integrations, across
  all six locales. Swap the full-sync icon from Download to RefreshCw and
  align its action-row icon metrics with the sibling cards.
- Test buttons: migrate Telegram and ntfy off their hand-rolled "Test
  message" buttons onto the shared TestConnectionButton, so every channel and
  integration test control shares one icon, label, latency readout, and error
  taxonomy. Retire the now-unused testMessage / testSent strings.
- ntfy auth-token placeholder no longer reuses the "Saved" status string;
  it now reads "Saved — enter new to replace" like the other secret fields.
- Save-success copy: collapse the moodLog and Telegram bespoke strings onto
  the shared settings.saved string.
- Tap targets: floor every size="sm" action-row button (sync / full-sync /
  test / disconnect / save / connect-secret copy) at min-h-11, fixed at the
  TestConnectionButton source and across the cards, so they clear the 44px
  touch minimum.
- Contrast: the onboarding source-card badge moves from muted-on-muted to
  text-foreground; web-push card body rhythm aligns on space-y-4; Mood Log's
  deprecation note drops its bespoke 11px-italic treatment for the shared
  sub-note style.

(cherry picked from commit 11931d14b7875b7e2184a3b373d7a34b842f8eae)
2026-06-05 03:45:04 +02:00
Marc-André Bombeck 53e87ba345 perf: consolidate integration status query, add mood-insights SWR, defer below-fold charts
Settings → Integrations fired five status round-trips on every mount —
the consolidated /api/integrations/status alongside the four per-provider
/api/<provider>/status queries. Extend the consolidated endpoint to carry
every field the four cards render (Withings activity scope, WHOOP/Fitbit
backfill state, moodLog webhook secret + entry count) and drive all four
cards off the single envelope. The legacy per-provider routes stay for the
iOS/test callers; the web cards no longer hit them.

Mood-insights cache was a plain 60 s TTL that was hard-evicted on every
mood write, so an active logger re-paid the multi-second cold compute on
every entry. Add stale-while-revalidate to ServerCache (wrapSwr / cachedSwr
+ a markStaleByPrefix that collapses the fresh TTL without dropping the
value), give the moodInsights bucket a 10-minute stale window, route the
read through cachedSwr, and switch the mood-write invalidation from a hard
evict to a mark-stale so the prior aggregate serves immediately while a
single background recompute warms a fresh one.

Defer the eager Recharts on the mood-insights surface. The trajectory
forecast card pulled Recharts into the initial chunk of every
trajectory-eligible metric page though the main chart is already deferred;
split its fan band into trajectory-fan.tsx behind next/dynamic. The three
below-fold mood mini-charts (distribution / weekday / time-of-day) were
static imports under an already-dynamic hero line chart; defer them with
next/dynamic. Each loader paints a skeleton sized to the chart's own band
so the chunk arrives without a layout shift; the charts stay Recharts and
visually identical.

(cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1)
2026-06-05 03:25:22 +02:00
Marc-André Bombeck 3186ecbe67 fix(settings): raise the Google Health tag contrast and update the integration-count assertion
The Fitbit & Pixel tag chip paired muted text on a muted background (below
WCAG AA); use foreground text. Update the mobile integrations e2e to expect
the fourth integration card (Google Health).
2026-06-05 01:33:42 +02:00
Marc-André Bombeck 6657e3da6f fix(ui): elevate the capture FAB and unify selectable-control and export-card styling
Lift the bottom-bar center capture action into a proper floating-action
button: a 56px target raised out of the bar with a card collar ring and
a stronger shadow, so it reads as the primary CTA rather than a fifth
flush tab. Desktop sidebar nav is unaffected.

Unify the selectable controls across the mood logging surface onto one
design language: the 5 mood faces, the tag tiles, the rated-factor steps
and the GLP-1 chips now share a single selected-state treatment
(border-primary, bg-primary/15, primary text, single border) and a
consistent rounding family. Match the More-hub row height to the capture
tiles so equivalent tappable rows share one min-height at the same tier.

Give the demoted doctor-report export card a primary-tinted icon to match
its peer cards — the demotion comes from position and size, not a muted
icon. Drop the doubled heading atop the mood sheet by demoting the hero
question to a field label so the sheet title is the single primary
heading. Reword the capture mood option description to describe the
action instead of echoing the hero question.

(cherry picked from commit 1296f151f1c9a7c95ff917e3d69a5c0d369ba210)
2026-06-05 01:05:47 +02:00
Marc-André Bombeck 61573301c3 fix(fitbit): single sync watermark, reject empty refresh token, correct interval time-anchor, mark provider experimental
Snapshot the incremental watermark once per sync cycle and thread the same
lower bound to every resource (metrics, activity, sleep, workout), stamping
lastSyncedAt once at the end. Previously each resource read lastSyncedAt then
stamped now() independently, so the first resource advanced the watermark and
later resources only fetched the last overlap window — silently dropping the
gap after an outage longer than the overlap. The full/backfill path is
unaffected (it passes no lower bound).

Reject an OAuth callback whose token response carries no refresh_token instead
of persisting an empty string. An empty refresh token bricks every future
token refresh; the callback now errors out cleanly so the user re-consents
rather than landing a dead connection.

Model the INTERVAL data types (steps/distance/calories/floors daily totals,
sleep and exercise sessions) on Google Health's interval.start_time /
interval.civil_start_time anchor instead of a sample_time / bare date. The
incremental filter for sleep/exercise previously targeted
sample_time.physical_time, which 400s/empties for interval types and stalls
incremental sync. Daily-summary metrics and spot readings keep their existing
date / sample anchors. Shape mismatches still fail safe (return []/null).

Mark the Google Health (Fitbit & Pixel) settings card as experimental with a
badge and a short note that data coverage is still being verified, in all six
locales.

(cherry picked from commit 3f116802149eebe9144729e2779f7f4405f248f2)
2026-06-05 01:05:47 +02:00
Marc-André Bombeck cfb97593a3 feat(insights): add tag-by-metric crosstab and deprecate the moodLog webhook
Extend the mood-relations surface from mood to a health metric. For each
structured mood tag present on enough days, compare a metric's value on
tag-present vs tag-absent days: active energy on workout days (same day),
sleep length on nights tagged sleep (same day), and next-day recovery
after a tag like alcohol or food (D to D+1 lag). The comparison reuses the
existing statistics engine wholesale — the Welch two-sample t-test, the
per-group day floors, the confidence band, and the Benjamini-Hochberg FDR
control already used by the influence and discovery boards — so no new
statistics code lands. Only pairs that clear p < 0.05 and the FDR control
surface, ranked by adjusted q then absolute delta and capped at eight rows.

The result rides on GET /api/mood/insights as a new additive
`tagMetricCrosstab` field and renders as a confidence-gated, observational
card on the mood insights page, framed as an association in your own data
rather than a cause. The endpoint is server-rendered and not in the OpenAPI
contract, so no contract change is required.

Deprecate the standalone moodLog integration. Mood is now tracked fully
inside HealthLog through entries, structured tags, and rated factors, so
the external webhook and reverse-sync bridge no longer add anything. The
webhook route, the push and pull helpers, and the validation schemas carry
an @deprecated note pointing at the native path; the surface stays
functional this release and is slated for removal in a future major. The
settings card shows a deprecation hint, localised across all six locales.
2026-06-05 00:33:15 +02:00
Marc-André Bombeck 280bf42382 feat(nav): add the center capture action and More hub, move unit preference into the profile
Reshape the mobile bottom bar to the five-slot shape the native client
uses: Home · Meds · Log · Insights · More. The center "Log" slot is a
capture action rather than a destination — it opens a picker that routes
to the existing measurement, medication-intake and mood quick-entry
sheets (those forms already share a mount contract, so the picker reuses
them verbatim). Expand the former overflow into a real "More" hub that
holds the rest of the top-level destinations: Measurements, Mood,
Workouts, Achievements, Notifications and Settings.

The change is additive — Measurements and Mood leave the always-visible
strip but stay reachable through the More hub and the capture picker, so
nothing loses its route. On desktop the sidebar still lists every
destination.

Move the metric/imperial unit system from its standalone card into a
dropdown in the Profile form beside the timezone picker — it is a
display preference like language and timezone. Persistence is unchanged:
the dropdown PATCHes /api/auth/me/unit-preference on change and
invalidates the same query keys, so chart display transforms re-render
without a reload. Retire the now-unused card.
2026-06-05 00:08:07 +02:00
Marc-André Bombeck d5eb887757 feat(insights): adopt the canonical per-metric detail template and terminology
Bring the web per-metric Insights detail pages to structural parity with
the canonical metric template: a single top-to-bottom spine, every block
self-suppressing when its data is absent.

New spine (above the chart): header (title + target gear + Coach circle)
→ intro → primary tile → Letzte Messung → stat strip; then the chart,
the range controls + period delta, the per-metric correlation card, and
the Einschätzung assessment last.

- Add the primary tile (headline value + 30-day average + an In-range
  bar shown only when the metric carries a per-metric in-target pct).
  Blood pressure keeps its richer target panel as its primary tile.
- Add the Letzte Messung card (labeled heading + last-reading date),
  reading the last-seen timestamp the slim analytics slice already
  carries — no extra fetch.
- Relocate the owning correlation onto its metric page: weight × weekday
  on Weight, mood × pulse on Pulse. The card reads the correlations
  already on the analytics payload and self-gates below the surfacing
  bar; it is now the canonical home for those relationships.
- Move the headline value, 30-day average and in-target share out of the
  target reference panel so each figure appears in exactly one place;
  the panel stays the band reference (range, status, positional bar,
  consistency strip, source). Blood pressure keeps its stitched S/D
  30-day average inline.
- Order the Einschätzung last on every page.

Terminology: adopt the canonical labels — rename the mood patterns
surface to "Was auffällt", pluralise the deviation-count card to
"Signale des Tages", and align the settings section labels to
"Über diese App" and "Quellen-Priorität". New In-range /
30-day-average / Letzte Messung strings land in all six locales.

Web charts are unchanged — Recharts and the existing chart tokens
stay; the iOS monochrome chart doctrine is not applied.
2026-06-04 23:03:05 +02:00
Marc-André Bombeck f44dccb828 feat(fitbit): add the Google Health settings card with connection test and resume
Give the Google Health (Fitbit & Pixel) integration the same settings-card
parity WHOOP and Withings already have. It is a BYO-key provider — the user
pastes their own Google OAuth client id/secret, then connects — so the card
mirrors the WHOOP flow rather than the server-brokered Withings one.

- New POST /api/integrations/fitbit/test does a live, rate-limited
  (5/60s per user) probe against the Google Health profile endpoint via
  the refreshed token from getValidToken, returning { ok, latencyMs,
  lastSyncedAt } and the same error-code union WHOOP uses. The Bearer
  token and upstream host are scrubbed before any annotation.
- New POST /api/integrations/fitbit/resume clears a parked integration
  via resumeIntegrationFromPark, rate-limited to match the test route.
- FitbitCard renders the credentials form, OAuth connect, sync now /
  sync all data / test connection / disconnect action row, the parked
  resume banner, and a backfill-in-progress line. Status reads from the
  existing /api/fitbit/status; the pill/error/parked state comes off the
  cross-integration envelope, which now carries a fitbit entry.
- Card strings added under settings.fitbit* across all six locales.
2026-06-04 22:37:58 +02:00
Marc-André BombeckandClaude Opus 4.8 d4801d4646 refactor(export): highlight the health-record export, demote the doctor report, collapse the included-data list
The Settings export page over-emphasised the doctor-report PDF: it owned
the page hero while the broader health-record export (PDF + FHIR R4 + zip
package) sat below it as a plain card. The relative prominence is now the
other way round.

- The health-record export panel takes the page hero, with the same
  hero-gradient + glow-purple treatment the Insights hero strip uses.
- The doctor-report card drops the hero styling and moves to the bottom
  of the page as a small secondary card. It stays fully functional — the
  same dialog + /api/doctor-report flow runs from there.
- The health-record "included data" checklist is now a disclosure,
  collapsed by default, so the panel opens compact instead of as a long
  always-expanded list. It uses the inline aria-expanded/aria-controls
  pattern already used elsewhere, with no new dependency.

Pure presentation/IA change: export routes, payloads, and the exported
data are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:43:02 +02:00
Marc-André Bombeck 86b2774567 feat(fitbit): add the Fitbit connection data model and FITBIT measurement source
Lay the data-model foundation for the Fitbit/Pixel integration over the
Google Health API, mirroring the WHOOP provider. No OAuth, sync, or UI yet.

- Prisma: FitbitConnection (1:1 per user, encrypted access/refresh tokens)
  and FitbitOAuthState models, two encrypted BYO-credential columns on User,
  and FITBIT appended to the MeasurementSource enum. Migration
  0117_v1120_fitbit_integration is additive only — one enum value plus two
  tables and two nullable columns, no new MeasurementType (every launch
  metric maps onto an existing type).
- Wire the two new encrypted columns and the FitbitConnection token columns
  into scripts/rotate-encryption-key.ts.
- FITBIT touchpoints: the read measurementSourceEnum (not the writable set),
  the DEFAULT_SOURCE_PRIORITY and workout-source ladders, the sources-section
  label map, the IntegrationKey union plus its display name, and a
  fitbit()/fitbitStatus() query-key pair. The source-rank SQL and read-tier
  collapse pick FITBIT up from the ladders with no code change.
- Add the FITBIT source label to all six locale bundles and regenerate the
  OpenAPI contract so the source enum carries FITBIT for the iOS decoder.
2026-06-04 21:02:10 +02:00
Marc-André Bombeck 874e286379 fix(insights): honest discovery footer, commensurable better-days ranking, settings tap-target + prop cleanup
- Mood discovery footer no longer pairs a mood-only numerator with the
  full behaviour-by-outcome denominator. Reword it to surface the mood
  relationships out of all day-to-day pairs the engine tested across
  every metric, so the count it reports is honest about its scope.
  Update all six locales.
- Rank the better-days board on a comparable scale: standardize the tag
  mood-point delta into a Cohen's-d effect (delta over the pooled
  with/without mood SD, clamped to [0,1]) so it sits alongside a metric
  |r| instead of the old |delta|/2 heuristic. Fall back to |delta|/2 only
  when the pooled SD is undefined. The pooled SD is ranking-only and is
  stripped before the row enters the snapshot payload; the raw delta and
  r the cards show are unchanged.
- Normalize the Withings and Whoop resume buttons to the min-h-11 scale
  token, dropping the arbitrary min-h-[44px] spelling.
- Give the API token copy button a 44px tap target on small viewports,
  scaling back to the tighter icon size from sm up.
- Drop the dead cached prop from InsightStatusCard and every caller; the
  card has carried no cached affordance since the badge was removed.
2026-06-04 19:39:37 +02:00
Marc-André Bombeck c0065437a7 refactor(settings): move unit-system and global injection-site exclusions to Account
The unit-system selector and the app-wide injection-site exclusions are
personal account preferences, not dashboard-layout controls. Move both
cards from the Dashboard settings section to the Account section, next to
the profile. The persistence path is unchanged — same endpoints, query
keys, mutation hooks, and labels; only the render location moves. The
Dashboard section keeps the layout/widget controls.
2026-06-04 19:13:47 +02:00
Marc-André Bombeck 6f378c8584 feat(whoop): add connection test and resume-from-park parity with Withings
Mirror the Withings integration actions on the WHOOP card. A new
POST /api/integrations/whoop/test runs a live, rate-limited probe
against the WHOOP basic-profile endpoint, refreshing the token via
getValidToken and classifying HTTP/transport failures into the same
errorCode taxonomy the Withings test route uses. A new
POST /api/integrations/whoop/resume clears a parked integration via
resumeIntegrationFromPark for the whoop provider.

The settings WhoopCard gains the shared Test-connection button, the
parked-state resume banner, and switches the full-sync icon to
RefreshCw for visual parity. Sync-now and sync-all are unchanged.
2026-06-04 19:13:47 +02:00
Marc-André Bombeck 227dd2b73f fix(settings): show a pending state on the danger-zone confirm buttons
The data-reset and account-delete confirmations fired their mutation
from an uncontrolled AlertDialog, so the action dismissed the dialog
the instant it was clicked and the only spinner lived on the now-hidden
trigger. A user on a slow link saw no feedback and could re-open and
re-submit a destructive, irreversible action.

Drive both dialogs from controlled open state and add an in-dialog
pending treatment to the confirm button — disabled, aria-busy, and a
spinner — mirroring the bulk-delete confirmation in the medication
intake history. The dialog now holds open while the request is in
flight; the data-reset closes on success, the account-delete keeps its
pending state through the post-delete redirect.
2026-06-04 18:22:02 +02:00
Marc-André Bombeck c24f42848e feat(settings): add copy button and revoke error toast to API tokens
The one-time API token rendered in a bare code block with no way to
copy it short of selecting the text by hand — the worst place for copy
friction since the value is shown exactly once. Add a copy-to-clipboard
button beside the token with a success toast, reusing the
clipboard-plus-toast pattern already used by the per-medication token
row. Surface an inline check affordance for two seconds after a copy.

Token revoke previously swallowed every failure: a non-OK response or a
network error left the user with no signal the token was still live.
Add an error toast on both paths so a failed revoke is visible.
2026-06-04 18:22:02 +02:00
Marc-André Bombeck 65b988c686 fix(settings): right-align danger-zone action buttons beside their labels
The data-reset and account-delete cards under Settings → Advanced
stacked their destructive button below the description. Move the
button to a right-aligned slot on the same row as the title and
description, matching the stack-on-mobile / right-align-on-desktop
contract the onboarding-tour and about cards already use
(flex-col gap-3 sm:flex-row sm:justify-between, button w-full sm:w-auto).
The AlertDialog confirmation, i18n keys, and destructive styling are
unchanged.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck ad3bbd5507 fix(settings): show a friendly message when an export download fails
The export cards rendered the raw "Download failed (500)" error string to
the user. Map the failure to a localized message across all six locales.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck eddc06d135 style(settings): align the mobile passkey card padding with sibling surfaces
The mobile passkey list rows used a tighter p-3 while every other card
surface in the account section uses the standard padding step. Bump to p-4
so nested card surfaces read consistently; radius stays rounded-lg.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck 51959a4737 fix(settings): give the API-token revoke button an accessible name and a larger target
The revoke icon button announced only "button" to screen readers and sat
at a 32px target. Add a localized aria-label and bump it to 36px.
2026-06-04 15:37:36 +02:00
Marc-André BombeckandClaude Opus 4.8 9c39afa352 fix(settings): per-fact pending state on coach-memory forget
A single shared forgetOne mutation meant forgetOne.isPending disabled
every per-fact forget button during one delete, so the whole list read
as frozen. Track the in-flight fact id via the mutation variables so
only the row being deleted disables and shows the spinner.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:25:55 +02:00
Marc-André BombeckandClaude Opus 4.8 2994954138 test+a11y: fix mocks/expectations for source-priority + dashboard ids; pair coach-memory spinner with motion-reduce
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:12:18 +02:00
Marc-André BombeckandClaude Opus 4.8 0949fadfb0 feat(dashboard): make the v1.10 additive metrics pinnable to Home
Add eight widget ids (cardio recovery, six-minute walk, stair ascent/descent speed, breathing disturbances, wrist temperature, falls, walking steadiness) to the dashboard widget catalogue + default layout (hidden by default) so they can be pinned via the layout PUT.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:16:26 +02:00
Marc-André Bombeck 37cec93d28 test: account for the WHOOP card + narrative route in inventory tests 2026-06-04 04:48:41 +02:00
Marc-André Bombeck cd3982a35c fix(integrations): surface the WHOOP dual-source vitals caveat on the connect card
When WHOOP and another source both supply a standard vital (resting heart
rate, blood oxygen, body temperature, respiratory rate, sleep stages), both
values can appear until a future update settles on a single preferred source.
Show this as a calm muted note on the WHOOP card so the known limitation reads
as documented behaviour rather than a bug. New whoopOverlapNote key across all
six locales.
2026-06-04 04:10:16 +02:00
Marc-André Bombeck 008160dc72 feat(whoop): settings surface — status, disconnect, credentials, sync routes + connect card
Fill the WHOOP route set beside the OAuth connect/callback: status reports
connection + token expiry + backfill progress, disconnect revokes the stored
tokens and parks the integration, credentials saves/clears the per-user BYO
client id/secret encrypted at rest, sync triggers a manual incremental or full
pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card
(BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice)
and surface whoop rows in the combined integrations-status envelope so the pill
reflects real failure state. WHOOP lives under the existing integrations
section -- no new slug.
2026-06-04 02:34:33 +02:00
Marc-André Bombeck 8e00e1615d fix(settings): add the sharing section icon to the placeholder map 2026-06-04 01:43:55 +02:00
Marc-André BombeckandClaude Opus 4.8 be94e43480 feat(share-links): owner share-link management in Settings
Add a Sharing section under Settings (between API and Export) where the owner
creates a read-only clinician share link, sees their active and inactive
links, and revokes one. Create wires to the C4 lifecycle API: it sends the
label, a rolling history window, an expiry (capped at 90 days, validated
before the round trip), an optional scoped FHIR-API toggle, and the FHIR
resource types. The raw token is revealed exactly once on create as a
copy-able `/c/<token>` URL; the list only ever carries the server's safe
projection (no token, since only its hash is stored). Revoke confirms through
an alert dialog and invalidates the list.

RSC page mounts a client island; reads unwrap the envelope and key off the
centralised query factory. New `settings.sharing` / `settings.sections.sharing`
strings land in all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:39:58 +02:00
Marc-André Bombeck 6db3bef413 feat(measurements): add WHOOP source, native score types, and source-priority ladders
Add the WHOOP integration data layer (migration 0111): seven new
MeasurementType values (HRV_RMSSD, DAY_STRAIN, WORKOUT_STRAIN,
SLEEP_PERFORMANCE, SLEEP_EFFICIENCY, SLEEP_CONSISTENCY, SLEEP_NEED,
ENERGY_EXPENDITURE_KJ), a WHOOP MeasurementSource, the WhoopConnection
and WhoopOAuthState models, and the per-user encrypted BYO-key columns
on User. Day-strain takes DAY_STRAIN (not the COMPUTED STRAIN_SCORE) and
RMSSD takes HRV_RMSSD (not the SDNN HEART_RATE_VARIABILITY) so a native
value never shares a bucket with a derived proxy.

Carry the new types through the completeness walls (units, value ranges,
chart tokens, PR direction, categories, list-meta colours/icons/labels,
HealthKit-mapping exclusions) and feed the cross-source picker: recovery
ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the
overlapping vital ladders and the workout ladder, plus skinTemperature
and respiratoryRate metric keys.
2026-06-04 00:49:31 +02:00
Marc-André Bombeck 9bdf76ea11 fix(settings): localise AI connection-test failure reasons
Return a stable, secret-free reasonCode from /api/ai/test alongside the
English reason, map it to a localised string in the AI settings panel,
and fall back to the plain reason for any unmapped code. A German
operator no longer sees an English failure sentence in an otherwise
localised panel.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck 5b5ab5795c fix(settings): guard AI test and regenerate against non-JSON responses
The test and regenerate handlers called res.json() unconditionally. A
proxy-rewritten HTML error page made that throw "Unexpected token '<'",
which leaked into the toast. Both handlers now check the content-type and
wrap the parse, showing a clean message on a non-JSON body. The test
handler also renders the new { ok:false, reason } payload verbatim.
2026-06-03 08:56:20 +02:00
Marc-André BombeckandClaude Opus 4.8 6d1587531f test(settings): expect the shortened notifications section label
The settings section reverts to the single-line "Notifications" /
"Benachrichtigungen"; update the shell title assertions to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:40 +02:00
Marc-André Bombeck 78b76d2ac5 feat(settings): split targets and sources, rename AI Insights section
Three related settings changes:

- Split the combined Targets & Sources page back into two pages: Targets
  (/settings/thresholds) owns the per-metric target-range editor and
  Sources (/settings/sources) owns the source-priority and device-type
  ladders. Each is its own left-nav entry and route, served by the
  dynamic [section] route. The former /settings/sources permanent
  redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
  is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
  bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
  pages.

Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck b99b07ad38 feat(profile): capture the insurer IK number
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.

The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.

Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
2026-06-01 23:24:10 +02:00
Marc-André Bombeck b99a015266 feat(medications): make injection-site tracking and rotation real
The injection_site column on intake events was read across several
surfaces but never written, so site tracking was effectively dead. Wire
it end-to-end as a per-medication opt-in, gated on an INJECTION delivery
form and default off.

- Schema: add medications.track_injection_sites + allowed_injection_sites
  and users.global_excluded_injection_sites (migrations 0099, 0100;
  additive, defaulted, no backfill).
- Intake write path: accept an optional injectionSite on the per-med,
  status-toggle, and bulk intake routes. Persist it only on a taken
  injection with tracking enabled, validated against the medication's
  effective allowed set (per-med allowed sites minus the user-level
  global exclusion, deny wins) — a disallowed site returns 422 (the bulk
  route skips the entry).
- Rotation: extend the least-recently-used recommender with an allowed
  filter; the picker disables sites outside the effective set.
- UI: per-medication tracking toggle + allowed-sites editor in the dose
  wizard step, a skippable post-dose site prompt on both medication
  cards, and a Settings card for the user-level global exclusion.
- Contract: injectionSite is additive on the intake request; OpenAPI
  regenerated. New GET/PATCH /api/auth/me/injection-site-prefs.

The site capture is always optional and never blocks logging a dose.
2026-06-01 20:25:34 +02:00
Marc-André Bombeck c6e782cdb8 fix(report): pass the in-app locale through to the pdf export
The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.

Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
2026-05-31 22:47:44 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André BombeckandClaude Opus 4.7 8ee7aaac44 chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:10:12 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00
Marc-André Bombeck 30ab2112db fix(apns): wire per-channel Test senden button to a real endpoint (v1.4.47.6)
TEST_ENDPOINTS map was missing APNS. Add /api/notifications/apns/test route + map entry.
2026-05-22 15:05:37 +02:00
Marc-André Bombeck 66a7f8b6fa chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
2026-05-22 09:18:52 +02:00
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
2026-05-21 23:13:21 +02:00
Marc-André Bombeck 324d4bb411 chore(release): v1.4.44 — REG-11 dashboard summary fix (iOS Home tile sparkline + value)
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.

Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
  latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
  ORDER BY bucket_start DESC) so the trailing N daily buckets per
  type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
  BodyFat already is — accounts with zero readings ever no longer get
  empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
  sparse accounts, zero-data accounts, within-7d guards.

Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
  picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
  cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.

Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).
2026-05-21 22:07:32 +02:00
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00