Commit Graph
1040 Commits
Author SHA1 Message Date
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é BombeckandClaude Opus 4.8 7db9608c4c fix(insights): align trend-card mood title, tighten assessment gap, lift briefing heading
Four small Insights layout fixes:

- Trends row: render the mood mini chart in the same flat shell the
  weight/blood-pressure mini charts use (`bg-card border rounded-md p-2`
  with a plain title div) instead of the Card/CardHeader chrome that
  stacked extra space above and below the heading. The three trend cards
  now share one title baseline and one height.
- Coach settings: place tone and response length side by side (two
  columns from `sm`, stacked on mobile) so the sheet no longer needs an
  extra scroll length for two short selects.
- Daily briefing: move the heading out of the card into an `<h2>` above
  it, matching the heading-above-card pattern the trends and wellness
  sections use, so the card opens straight on its content.
- Assessment cards: align the loading skeleton and the mood better-days
  card to the canonical `gap-1.5` + `pb-1` heading-to-body rhythm the
  populated state already uses, removing the loose ~16-24px gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:52:56 +02:00
Marc-André BombeckandClaude Opus 4.8 ba17b08a01 feat(medications): split the Add button into log-intake vs new-medication
The top medications-page Add button now opens a choice menu with two
paths: log an intake against an existing medication — including a
backdated one — or create a new medication (the existing wizard,
unchanged).

The log-intake dialog lists the active medications, optionally pins a
schedule slot, and picks a date and time that defaults to now but accepts
any past instant. It submits to the existing per-medication intake route:
takenAt carries the (possibly backdated) instant, and a chosen slot
supplies scheduledFor so the write routes through the server's canonical
slot upsert — the same snap path a live Taken tap uses — keeping the
one-row-per-dose-slot invariant intact. No new endpoint.

Submit orchestration lives in a pure, dependency-injected runLogIntake
helper alongside the shared take/skip logic, invalidating the medication
dependent query-key bundle so the dashboard and compliance views refresh.
New strings land in all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:50:08 +02:00
Marc-André BombeckandClaude Opus 4.8 2beaec4149 feat(medications): surface the open-cycle status on the medication cards
Wire the per-med `complianceDisplay.currentCycle` descriptor into a calm,
rate-decoupled status line on both the generic and GLP-1 medication cards.

A sparse weekly or rolling med (a GLP-1 injection) that is simply between
doses has no closed cycle in flight, so the compliance percentage alone reads
as a scary 0% that says nothing about whether the user is on schedule. The new
`MedicationCycleStatus` card-part reports the open cycle instead: "next dose in
N days" / "due today" / "overdue", or a neutral "no closed dose cycles yet"
when there is not enough history to score. It complements the percentage rows;
it does not replace them.

Driven by `currentCycle.state` (on_track / due / missed / none). Colour follows
the existing success / warning / destructive ramp and pairs each tier with a
Lucide glyph so colour-blind users can disambiguate (WCAG 1.4.1). The relative
day phrasing buckets on the user's calendar day, matching the next/last slot.
Strings land in all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:49:29 +02:00
Marc-André BombeckandClaude Opus 4.8 20d7187b0d feat(insights): premium animated wellness-score rings
Rebuild the wellness-score strip on /insights as a signature, high-end moment
instead of a flat dial.

- Five distinct per-metric hues — readiness green, recovery teal, sleep
  indigo-blue, stress amber, strain violet — hand-tuned per theme so each holds
  contrast on the white light-mode card. Fixes the dark-theme collapse to a
  cyan/green wash (the semantic info token resolved to the same cyan as three
  rings) and the light-theme wash-out of the raw cyan on white.
- A spring-eased arc sweep that overshoots ~6% then settles, a left-to-right
  staggered reveal with each number trailing its own arc, a conic specular
  sheen that sweeps once, a token-tinted bloom (a gentle breathing pulse only
  on a top-band ring), a segmented instrument-style track, and a faint
  film-grain tile surface for material depth.
- A "vs your normal" ghost arc under the live arc for recovery, stress and
  strain, derived from the trailing score series already on the payload, so the
  user sees today against their baseline at a glance.

The reveal plays once per browser session (sessionStorage-gated) so a
background sync refetch never re-triggers it. All motion is CSS-first — no
animation dependency, @property + a sampled linear() spring — and is fully
disabled under prefers-reduced-motion. The arc stays a hand-rolled SVG gauge;
the band semantic still rides data-band + the band word + the aria-label, never
colour alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:40:00 +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 e7d34eb853 feat(insights): premium wellness-score tiles + SVG ring redesign 2026-06-05 22:09:06 +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 d3d58a0c30 chore(release): v1.13.1 — wellness rings fill to the actual score
The score-ring's RadialBar swept the full circle for any value because the
angular domain was only pinned on the PolarRadiusAxis (which governs radius,
not sweep). Recharts then auto-scaled the angular domain to the single datum,
so every ring read as 100%. Add a PolarAngleAxis with a fixed [0,100] number
domain so the bar sweeps value-percent of the track. Affects the overview
wellness tiles and the score-anatomy detail view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:18:52 +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 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 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 83cf4eff2e fix(medications): split the card next/last dose lines into label-left, value-right rows
The next-intake and last-intake lines on the medication cards ran the
label and the time together on one run of text, reading as crammed. Lay
each line out as a two-column row — bold label pinned left, value flush
right — so the two rows read as distinct, aligned columns. The label
truncates first on a narrow viewport; the time never truncates.

The GLP-1 card routes through the same shared slot and now overrides only
the left-column label with its appointment-phrased wording, so the value
column carries the time and the optional rotation nudge. Its value strings
drop the now-redundant leading label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:34:44 +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 a3e741c23c feat(mood): revise factor + tag catalog (migration 0126)
Tighten the operator-finalised mood catalog to three RATED factors and a
trimmed default BINARY tag set.

- Seed `factor_sadness` (RATED, 1..5, inverse — higher = worse) under the
  `factors` category, with its label in all six locales and a CloudRain
  glyph. The RATED set is now work / sleep quality / sadness.
- Retire `factor_conflict`, `factor_family`, `factor_social`,
  `factor_stress`: flip each to BINARY and deactivate. The catalog rows
  stay so historical entries still resolve their labels (the insights read
  joins by id without an active filter); they just leave the RATED set and
  the default picker.
- Trim the default BINARY tag set roughly in half: deactivate the
  sleep-tag category (now covered by the sleep-quality slider), the mood
  level restatements, the nutrition long-tail, and low-signal day-type
  tags. Kept rows are the high-signal, correlatable tags. Nothing is
  deleted; inactive rows still resolve labels for existing entries.

Reference-data only — no schema change, idempotent upsert + closed-key
updates, no per-user data touched.

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é BombeckandClaude Opus 4.8 2f52d49d91 fix(insights): tighten the assessment card header-to-text gap
Trim the assessment card flex gap to gap-1.5 (from gap-2 md:gap-3) and the
header bottom padding to pb-1 (from pb-2) so the TileHeading sits close to its
prose, matching the header-to-body rhythm of the sibling tiles. Applied across
the success, preparing, no-provider, and no-analysis states; the loading
skeleton keeps its own spacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:53:32 +02:00
Marc-André BombeckandClaude Opus 4.8 cde8827870 feat(insights): order pulse assessment above cardio-fitness link; harmonize its header
Render the pulse Einschätzung above the VO₂ max cardio-fitness cross-link so
the plain-language read leads and the cross-link trails. Lead the cross-link
card with the canonical TileHeader so its Gauge glyph matches every other tile
header (foreground colour, same size and position) instead of the muted,
offset placement.

Allow the documented trailing CTA in the spine-order guard for pulse while
keeping the target-before-assessment check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:53:32 +02:00
Marc-André BombeckandClaude Opus 4.8 465c8b8e33 feat(insights): drive metric stat strip from the chart range tab; add chart tile header
Remove the draggable chart brush. The Min/Max/Median/Mittelwert strip now
always reflects the data visible under the chart's active range tab (7/30/90/
all): the chart reports per-type stats for its range-sliced series and the
sub-page threads them into the strip. No drag, no "selected range" pill — the
range tab is the single selector for both chart and strip. Blood pressure
recomputes both systolic and diastolic.

Give each metric chart card the canonical TileHeader (icon + title, same size
and spacing as the assessment/target/stat tiles) via an optional titleIcon;
the dashboard and mini mounts keep the compact heading.

Drop the now-unused charts.selectRange and insights.subPage.stats.selectedRange
keys from all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:53:32 +02:00
Marc-André BombeckandClaude Opus 4.8 278007ce36 feat(insights): give wellness dials a white ring on a gradient tile
Echo the hero card on the wellness-scores strip: each ring tile now rides
a saturated purple-pink gradient (.score-tile-gradient) and the dial paints
a white arc over a translucent-white track with rounded ends.

The gradient mixes over the fixed dark --dracula-bg rather than the theme
--card, so it stays dark in both themes and white copy clears WCAG AA
either way (white on the purple endpoint ~5.0:1, pink endpoint ~6.1:1).

ScoreRing gains a variant prop: onGradient (white arc, white number) for
the tile, band (band-coloured arc) by default so the score-anatomy detail
view stays legible on its plain card. The band semantic is never lost to
the white arc - it still rides data-band, the aria-label, and the band-word
label under the ring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:49:12 +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 d2edaeb8d1 refactor(insights): point the pulse page to the cardio-fitness surface for VO2 max
VO2 max had two divergent surfaces — the dedicated /insights/cardio-fitness
page (full chart + plain-language assessment) and a legacy compact chart-row
on the pulse page. Replace the pulse-page row with a clear cross-link to the
dedicated page so there is one VO2 max surface, and remove the now-unused
chart-row component + its test. New cardioLink copy added across all six
locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:07:56 +02:00
Marc-André BombeckandClaude Opus 4.8 4847d2fc40 fix(insights): surface an error + retry on the overview, not the empty state
The overview empty-state gated on the comprehensive query reporting zero
measurements, but a transient 500 / network drop settles the query with no
data too — so a user with history saw the false 'no data yet — add a
measurement' prompt with no way to recover. Add an isError branch above the
empty-state that renders a load-error message plus a Retry that refetches the
query, mirroring the vitals-dashboard pattern. Reuses the existing
insights.loadError + common.retry keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:07:56 +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é 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 1f00946bfe refactor(insights): tighten the subpage spine and clarify the VO2 max chart
Pull the subpage spine to a denser space-y-3/4 rhythm to match the tighter
stat strip, and refresh the stale brush comment now that the windowed stats
are wired. Give the cardio-fitness page its Gauge stat-strip glyph so VO2
max leads with the same standard treatment as the other HealthKit metrics;
the dedicated subpage already renders the full chart. Clarify the VO2 row's
sparse-series note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 d0d0f49385 feat(insights): add loading skeleton and error+retry to HealthKit subpages
The HealthKit metric pages consumed only `{data, isEmpty}` and ignored the
hook's `isLoading`/`error`, so they painted nothing in flight and fell
through silently on failure. Branch on `isLoading` to a stat-strip + chart
skeleton, and on `error` to a message plus a Retry that re-issues the
analytics query (mirrors the vitals-dashboard pattern). Expose `refetch`
from the analytics hook and add a `subPage.loadError` string in all six
locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 60c5db8f60 feat(insights): combine the blood-pressure stat strips into one card
Render systolic and diastolic as side-by-side columns in a single strip
card rather than two stacked cards, reclaiming the vertical space the
operator flagged. Each column keeps its header and its four stats, and the
single chart's brushed window still updates both columns independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 ad2f038963 feat(insights): tighten the stat strip and add a side-by-side multi-series mode
Drop the strip card to a denser py-3/gap-2 rhythm with 44 px stat cells and
text-base values so the numbers-first header gives away less vertical space
on every metric subpage while staying readable. Extract the per-series block
(header + four stats + brushed-window pill) into a reusable piece and add a
`series` prop that renders multiple series as columns in ONE card (two on
desktop, stacking on narrow mobile). Single-series callers keep their
contract unchanged; the brushed-window stats stay per-series.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 80c5f14748 feat(insights): reorder the mood page to the calendar-first spine
Lift the Stimmungskalender above the line chart and place the better-days
assessment directly under the Ziel card. `MoodInsightsSections` now renders by
region (`heatmap` / `assessment` / `rest`); the page threads them so the spine
reads heading → summary → calendar → chart → target → assessment →
classification + breakdowns. All three regions share the one `moodInsights`
query so the fetch is deduped. Every `data-slot` is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:46:42 +02:00
Marc-André BombeckandClaude Opus 4.8 56934fd658 style(insights): move mood colored text onto semantic feedback tokens
The mood confidence chips, the better-days / influence / crosstab delta
read-outs, and the mood-chart comparison caption coloured their TEXT with
raw `--dracula-*` values. Those stay bright green/cyan/red/purple in light
mode (Alucard) and fail WCAG AA on the white card. Swap the coloured-text
surfaces to the semantic feedback tokens (`--success` / `--info` /
`--destructive`) and the theme-aware `dose-accent` purple, which carry the
light-mode overrides that clear AA in both themes. Decorative chart fills,
strokes, heatmap cells, and legend swatches keep their `--dracula-*` tokens —
those are non-text and already token-based.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:46:37 +02:00
Marc-André BombeckandClaude Opus 4.8 3cd04533be style(insights): tighten mood chart heights and unify the overview rhythm
The mood line chart plots a 1-5 score band; the 240/280 px full-mode height
left a dead strip below the line on the mood subpage. Trim the default to
200/220 px (a per-mount --chart-height override still wins) and lower the
mood-correlation scatter height so the cards fit their content without the
empty gap.

The overview used space-y-8 between top-level blocks while the vitals wrap used
space-y-6, reading as two tiers. Unify to space-y-6 for one consistent vertical
rhythm down the page, in line with the denser-Insights direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:40:44 +02:00
Marc-André BombeckandClaude Opus 4.8 4be9767428 fix(insights): normalize the assessment-card tile-header icon size
The TileHeader nodeIcon path rendered the caller-passed icon node verbatim and
dropped the canonical h-5 w-5 text-foreground class TileHeader forwards, so a
caller passing an unsized node could drift off the contract. Forward the class
onto a sizing span that pins any child SVG to the canonical box regardless of
what the caller passed. Existing call sites that already pass a pre-sized icon
stay visually identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:40:44 +02:00
Marc-André BombeckandClaude Opus 4.8 68ab78d6d8 feat(insights): merge the two mood "what stands out" surfaces into one card
The mood page carried two separate "what stands out" tiles: the narrative
takeaway feed and the FDR-controlled discovered relations, each a full card
under the same heading. Fold them into a single card — one header, the
narrative one-liners, then the discovered relations as a labelled subsection.
Each half gates independently; the card degrades to nothing when both are empty.

Drop the muted FDR explainer paragraph: the statistical caveat rides the
subsection info-icon tooltip, and every finding now reads in the standard
foreground text colour. Remove the orphaned discovery.description string from
all six locales and add the subsection label.

Give the better-days board the assessment-card treatment (insight-assessment
hook, tighter gap/padding, the insight-in entry) so it reads as the mood
assessment, consistent with the per-metric subpages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:40:44 +02:00