Commit Graph
1003 Commits
Author SHA1 Message Date
Marc-André Bombeck fcf5114620 chore(mood): drop the superseded tag-taxonomy seeder
The structured tag catalog is seeded directly by migration
0101_v185_mood_tag_taxonomy and served from Postgres via the
/api/mood/tags route, so the parallel TypeScript copy had no
importer and tripped the knip gate.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck a5c894b58e i18n(medications): translate the injection-site strings for es/fr/it/pl
The injection-site / log-dialog / settings cluster shipped translated in
de and en but verbatim English in es, fr, it and pl, so those users saw
English copy in the medication wizard, the log-injection-site dialog and
the settings injection card. Localise the eleven keys and drop the two
orphaned injection-rotation keys in the same bundles.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck 6bc51249ba chore(i18n): drop two orphaned injection-rotation keys
medications.injectionRotationOverviewTitle and
medications.injectionRotationNextSuggested have no t() reference anywhere
in the tree — leftovers from an injection-rotation overview that did not
land. Remove them so the bundle carries only live keys.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck f29ac6d96e fix(a11y): give the injection-site picker a visible focus state and accessible names
The SVG site picker painted its focus ring as the stroke of a transparent
hit-target circle — effectively invisible on the muted body map, a WCAG
2.4.7 risk — and conveyed the recommended next site only through the
dashed ring (no screen-reader cue). Track keyboard focus and paint a
contrasting halo on the filled dot, and fold the recommendation into the
recommended site's aria-label so the rotation guidance reaches assistive
tech. Adds injectionSiteRecommendedAriaLabel across all six locales.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck ad693cd862 refactor(medications): extract the compliance slot-context builders
expectedSlotCountForDay and expectedSlotsBetween each rebuilt the same
canonical-schedule and recurrence-context literals inline, differing only
by id prefix and count-vs-collect. Extract toRecurrenceCtx and
toCanonicalSchedule so both share one adapter; behaviour identical.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck 7e7702d6c9 refactor(medications): single-source the injection-site allow check
resolveInjectionSiteForWrite reimplemented the effectiveAllowedSites(...)
.includes(...) membership test inline, duplicating the isSiteAllowed
helper that already wraps it. Call isSiteAllowed so the write-path guard
and the helper share one definition.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck fef88787e9 fix(medications): surface a multi-day-overdue first rolling dose
A rolling cadence with no intake logged anchors its first dose at
startsOn ?? createdAt. When that anchor falls on a prior calendar day
the dose is overdue by a day or more, but nextOccurrenceAfter floored
the search window to the start of the user's current day and dropped it
— so the dashboard showed no next-due and the reminder worker, which
shares this engine, emitted nothing. Clamp the floor to the first-dose
day in the no-intake branch so a multi-day-overdue start dose surfaces
as overdue. The post-intake re-anchor (lastIntakeAt + N) is unaffected.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck b5e98533b8 fix(export): move HealthKit placeholder codes off the LOINC namespace
Six HealthKit metrics without a published LOINC term were coded under
http://loinc.org, which fails FHIR R4 conformance (a non-LOINC code must
not sit in the LOINC namespace). Route any HKQuantityTypeIdentifier code
onto a shared custom CodeSystem (https://healthlog.dev/fhir/CodeSystem/healthkit),
keeping the raw HealthKit identifier as the code so the export stays
byte-aligned with the native client's exporter.
2026-06-01 20:46:49 +02:00
Marc-André Bombeck 0f7ca61e2f feat(insights): port the target reference panel onto the category pages
Grow the per-category insights target summary from a one-line range card
into a compact reference panel that surfaces the band range, the verbal
status, the guideline source, the in-target share, the 30-day average,
the range bar and the 7-day consistency strip — all from the Targets
data already on the wire, reading the same shared cache. Extract the
status pill and the source-link resolver into shared primitives so the
Targets card and the insights panel can never disagree, and wire blood
glucose into the summary with its per-context split and unit conversion.
2026-06-01 20:42:40 +02:00
Marc-André Bombeck 5de9ddb0cc feat(insights): drive the Trends charts from the daily briefing
The overview Trends row used to render a fixed BP / weight / mood
triple regardless of what the day's data surfaced. It now mirrors the
daily briefing: the row charts exactly the metrics the briefing flags,
in the briefing's priority order, deduped and capped at three. When no
briefing is available — cold mount, a web-only account with no
findings, or a pre-briefing cached payload — selection falls back to
the legacy BP / weight / mood triple so the row never paints empty.

Selection lives in `trend-chart-select.ts` as a pure, unit-tested
mapping from each briefing `sourceMetric` to a chart descriptor
(series, colour, unit, title key). Adherence-context findings
(compliance, GLP-1 plateau) have no standalone trend series and are
skipped so the next finding fills the slot. Annotations stay on the
legacy triple; additive HealthKit metrics render chart-only. The chart
set is derived from the briefing payload already on the page, so there
is no new blocking call.
2026-06-01 20:26:31 +02:00
Marc-André Bombeck 6ce09cc76d feat(insights): stat strip, range band, all-readings subpage, diversity nudge
Add the data-value features to the insights category pages on top of the
density pass:

- Stat strip: lead each metric page with min / max / median / mean,
  read from the already-fetched analytics summaries slice (no new
  network). Median is now computed in both summary producers — exact in
  the live path, a windowed PERCENTILE_CONT in the rollup-tier slim
  slice. The thin HealthKit pages mount it too for parity; no AI
  assessment is added there.
- Range band: shade an Apple-Health-style min-max area around the daily
  mean line where the data supports it (non-cumulative metrics). The
  daily-aggregate read now emits per-day min / max from the rollup
  buckets; the band rides the existing target-range toggle.
- All readings: a dedicated per-metric subpage at /insights/values/<type>
  reusing the measurement list pinned to one type (pagination + inline
  edit/delete). A clear entry button mounts at the foot of each
  category page.
- Diversity nudge: a gentle hint when a metric's readings cluster on one
  weekday or time of day, computed from a bounded, cached timestamp
  window — non-blocking, self-gating.

All new reads stay cache-friendly and off the navigation path.
2026-06-01 20:26:26 +02:00
Marc-André Bombeck cf649f72ce feat(mood): structured tag taxonomy + note capture and timeline
Add the Category -> Tag taxonomy the mood surface was missing and the
free-text note the web form could never write, then surface both on the
Insights mood page.

Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog
plus a MoodEntryTagLink join, seeded with a default five-category set
(i18n-keyed labels, Lucide icons). Purely additive — the legacy flat
MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram /
MoodLog sync are untouched; an entry can carry both axes.

Capture: the mood-logging form and the edit sheet gain a Textarea for
the note (model + API already accepted it) and an icon-chip picker over
the taxonomy catalog (GET /api/mood/tags). Create / update resolve the
picked keys to links; unknown keys drop silently against the catalog.

Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and
a notes timeline join the existing distribution / weekday / flat-tag
sections. The aggregate extension also fixes the flat-tag axis, which
read the raw JSON string and so never populated.

i18n across all six locales. Tests cover the taxonomy aggregation, the
note round-trip, the structured-tag schema, and the notes timeline.
2026-06-01 20:25:42 +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 84e592b17e feat(insights): enrich the mood page with calendar, distribution, weekday, and correlation views
Lift the mood aggregates the AI status generator already computes —
tag frequency + per-tag average, in-target share, and the
mood × metric Pearson correlations — into a shared
`mood-aggregates.ts` so the prose and the new charts read off one
implementation. `mood-status.ts` now consumes the shared helpers
instead of its own inline copies.

Add a cached `/api/mood/insights` endpoint that returns the
pre-computed shapes (summary, heatmap, distribution, weekday, tags,
correlations) behind a 60-second server cache and a bounded 365-day
read — a cheap read-only path, never a synchronous model call. The
bucket invalidates alongside the mood-analytics cache on a mood write.

Render the shapes on the mood Insights page:

- a calendar heatmap forked from the compliance heatmap, coloured by
  the same mood-score bands as the line chart, with an adaptive
  window (30 days until ~90 days of history, then 90, then a full
  year past 180 days) so the grid is never mostly empty;
- a mood distribution bar chart (share of days per level);
- average mood by weekday with a best/toughest-day callout;
- a tag / trigger breakdown (frequency bars + per-tag average);
- mood vs. sleep / activity / pulse correlation cards reusing the
  existing scatter chart.

Add the supporting i18n keys across all six locales.
2026-06-01 19:54:26 +02:00
Marc-André Bombeck 1fcc5ba96b feat(medications): adapt the compliance display to cadence density
A rolling 7-/30-day percentage is meaningful for daily and weekday
medications but noise for sparse cadences: a med dosed every few weeks
has at most one expected dose in a 7-day window, so the bar swings
between 0 and 100 off a single sample.

Decide the display server-side from expected-dose density. When the
30-day window holds at least four expected doses the percentage bars
stay; below that the card swaps to an uptime-style per-dose timeline
where each cell is one expected dose — taken, missed, skipped or
upcoming — with a "last N doses" summary and a dose-indexed streak.

The compliance route returns an additive complianceDisplay block
(mode, expected7/30, the dose timeline, the summary) alongside the
existing compliance7/30 fields, which the iOS client and the Health
Score read unchanged. Read-path only; no schema change.

- compliance.ts: add expectedSlotsBetween and buildComplianceDisplay
  over the canonical recurrence engine
- dose-adherence-timeline: plain flex cells reusing the heatmap colour
  tokens and tap-to-pin tooltip
- switch the standard and GLP-1 cards on the server-decided mode
2026-06-01 19:54:26 +02:00
Marc-André Bombeck 93790aa043 feat(measurements): consolidate gait/mobility HealthKit metrics nightly
Extend the daily-mean consolidation to cover the high-frequency
gait/mobility metrics that previously mapped to no consolidation set and
piled up at sampling granularity: walking asymmetry, walking
double-support, walking steadiness, and walking heart-rate average. The
correct daily reduction for these is the mean, matching the Health-app
display; all four stay disjoint from the cumulative (sum) set.

Fold the daily-mean drain onto the existing nightly cumulative-drain tick
so new high-frequency spot samples no longer accumulate raw between worker
reboots. The two passes share one concurrency-1 cron, keeping the
maintenance window a single sequential walk. Boot-time discovery stays as
the back-fill for accounts that accumulated raw rows before this shipped.

Recompute the affected day's rollup bucket after the consolidation
commits, the same way the ingest path does, so a rollup-covered read
reflects the single consolidated row rather than a stale mean derived from
the now soft-deleted per-sample rows.

Regular heart-rate / pulse stays raw: it is a discrete clinical reading
and feeds correlation and scatter analytics. No schema change.
2026-06-01 19:54:26 +02:00
Marc-André Bombeck 5771b36899 feat(insights): tighten category-page density and balance the overview hero
Polish the Insights surfaces for a denser, more deliberate rhythm.

Category pages:
- Collapse the double caption under the heading. The explainer
  definition and the description paragraph used to stack, so every
  metric page opened with two near-duplicative muted lines before any
  data. Keep the explainer body as the single caption; render the
  description only when no explainer is set.
- Tighten the header gap (space-y-1.5) and the block rhythm
  (space-y-4 md:space-y-5) so the heading-to-first-data distance
  shrinks by roughly one text block.
- Demote the target summary from a full bordered card to an inline
  band (bg-muted/30 rounded-lg px-3 py-2). One line of range text no
  longer reserves the card primitive's py-4/py-6 shell padding.
- Close the gap under the assessment heading. The card primitive's
  gap-4/gap-6 flex gap floated the title well above its prose; override
  to gap-2 md:gap-3 with py-4/py-5 across every status variant.

Overview hero:
- Condense the "ask the coach" band — denser prompt chips and a
  bottom-pinned strip so the left column stops out-running the
  Health Score card.
- Give the Health Score breakdown more vertical presence (looser row
  rhythm, taller component bars) so the right column fills the row
  height instead of leaving the bottom empty.
2026-06-01 19:54:26 +02:00
Marc-André Bombeck d660279c97 fix(medications): stop the wizard showing each time-of-day suggestion twice
The schedule wizard's time-of-day step rendered the four suggested times
twice: once as the icon chips from the step itself, and again as the
labelled preset row built into the chip component below. Add an opt-out
on the chip component and pass it from the step so only the icon-based
suggestions remain. The period is still conveyed by the icon plus an
aria-label; the offered times and selection logic are unchanged. The
preset row stays on by default for every other caller.
2026-06-01 19:37:58 +02:00
Marc-André Bombeck ddb08b048a fix(medications): stop the delete confirm hanging in a spinner
Deleting a medication from the danger zone awaited a prefix-invalidate of
the "medications" key bundle before navigating. That bundle covers the
detail page's own active query, so the invalidation refetched the just-
deleted resource, hit a 404, retried with backoff, and never resolved —
the confirm button spun forever even though the delete had persisted.

Evict the detail query before invalidating, navigate to the list first,
then fire-and-forget the dependent-bundle invalidation. The busy state is
also cleared on the failed-response path. The detail query opts out of
retries so a deleted-resource refetch never burns a backoff cycle.
2026-06-01 19:36:33 +02:00
Marc-André Bombeck b3f61d0c75 fix(medications): surface the first rolling dose at the start date
Rolling schedules anchored next-due on start + N days when no intake had
been logged yet, which skipped the dose due AT the start date and
suppressed its reminder (the worker shares this engine). The +N cadence
now begins only after the first intake is logged: with no intake the
first dose is due at startsOn (or createdAt). A past start date with no
intake surfaces the dose as due/overdue rather than rolling it forward —
nextOccurrenceAfter floors the rolling search to the start of the user's
day so an overdue or due-earlier-today dose stays visible.

The post-intake re-anchor (lastIntakeAt + N) and the endsOn cap are
unchanged; PRN, one-shot, RRULE and legacy cadences are unaffected.
2026-06-01 19:35:11 +02:00
dependabot[bot] eea6385bc5 chore(deps): Bump actions/setup-node from 4 to 6 (#222)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 18:00:21 +02:00
dependabot[bot] b1cc1f86f5 chore(deps): Bump gitleaks/gitleaks-action from 2 to 3 (#221)
Bumps [gitleaks/gitleaks-action](https://github.com/gitleaks/gitleaks-action) from 2 to 3.
- [Release notes](https://github.com/gitleaks/gitleaks-action/releases)
- [Commits](https://github.com/gitleaks/gitleaks-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: gitleaks/gitleaks-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 18:00:16 +02:00
Marc-André Bombeck 16fdc75513 chore(release): v1.8.4 — medication card accuracy + Insights polish
The medication card reads the schedule engine's next-due (fixes
interval/one-time schedules that never advanced after a dose), logging a
dose confirms itself, Insights status polling is bounded, each Insights
category explains itself inline, medication history shows each dose's
origin, and an idempotent migration adds the app_settings columns that
were only ever applied ad-hoc.
v1.8.4
2026-06-01 17:37:11 +02:00
Marc-André Bombeck 65bbedd83d fix(seed): repair demo seed against the current schema
Two gaps stopped a fresh demo seed:

- medication_intake_events gained a NOT NULL updated_at column in the
  v1.7.0 sync keyset work. The raw INSERTs omitted it; set it alongside
  created_at on every intake row so the seed does not lean on the
  column's migration-only default.

- app_settings carried four columns on the schema (default_locale plus
  the telegram/ntfy/web-push global toggles) that never landed in the
  migration history — applied via db push to existing environments but
  never committed. A database built from migrations alone is missing
  them, and the admin settings read path selects default_locale, so a
  fresh deploy reports "column default_locale does not exist" on first
  read. Add an idempotent ADD COLUMN IF NOT EXISTS drift-fix migration
  mirroring 0025_user_locale_drift_fix: a no-op where the columns exist,
  a clean add on a migrations-only build.

Verified end to end against a fresh Postgres 16 built from the full
migration set: the seed completes and re-runs cleanly.
2026-06-01 17:31:45 +02:00
Marc-André Bombeck 65cfa2b83a fix(insights): bound the status preparing-poll to a fixed ceiling
The per-metric status cards and the inline medication-compliance query
poll the read-only status route every 4 s while the payload reports
preparing:true. A configured provider whose generation persistently
fails leaves preparing pinned true, so an open Insights page would poll
indefinitely — steady battery and network draw on a screen the user has
walked away from.

Cap the poll at 12 attempts (~48 s) through a shared
nextStatusPollInterval helper both call sites route through. Past the
ceiling the card settles on its existing static / empty state. The happy
path is unchanged: a generation that lands within a couple of intervals
clears preparing and stops the timer exactly as before, and a terminal
no-provider payload never carried preparing so it stays terminal.
2026-06-01 17:31:45 +02:00
Marc-André Bombeck 8dced00188 feat(medications): label each intake history row with its logging origin
The intake history surfaced the coarse IntakeSource enum directly, so a
dose logged from the iOS app showed nothing meaningful. Map the enum to
the practical client it represents — WEB to Web, API to iOS App,
REMINDER to Reminder, IMPORT to Import — and render it as a muted
"via {origin}" caption on each intake row next to the status. No schema
or API change; the source value already rides the existing intake
response. Tighten the en/de labels that read "External API" and
"Telegram / Reminder" into the same origin vocabulary across all six
locales.
2026-06-01 17:31:02 +02:00
Marc-André Bombeck c2808a9c70 feat(insights): show the metric explainer inline under each category heading
The per-category explainer text only lived behind the "?" popover next to
the heading. Render the same localised definition inline as a muted caption
directly beneath the title, where the short scaffold line already sits, so
the one- to two-sentence definition is discoverable without a tap.

Both surfaces read the identical insights.subPage.explainer.<metric>Body
key, so there is no duplicated copy and all six locales stay covered. The
"?" glyph stays in place as the always-available pointer to the same text.
HealthkitMetricPage already forwards explainerMetric to the shell, so every
routed metric sub-page picks up the inline caption from this one change.
2026-06-01 17:31:02 +02:00
Marc-André BombeckandClaude Opus 4.8 0e3a8f9cd3 fix(medications): read next-due from the server and confirm intake actions
Two fixes on the medication cards.

Next-due: the card recomputed "next intake" client-side from
`daysOfWeek` + `windowStart` alone, ignoring rolling intervals, RRULE,
one-shot doses, and the last-intake anchor. A rolling medication (e.g. a
35-day interval) showed "tomorrow" and never re-anchored after a dose.
`GET /api/medications` already returns `nextDueAt` per medication,
computed by the canonical recurrence engine anchored on the last intake.
Both card variants now render that value directly: the generic card
drops its `getNextOccurrenceTimestamp` walker, and the GLP-1 card drops
its weekly-only predictor that hardcoded a 7-day step. The day-label and
window-range rendering are unchanged; only the timestamp source moves to
the server. `nextDueAt` is threaded through the list page's medication
type into both cards.

Intake confirmation: tapping Taken / Skipped now raises a toast naming
the medication ("… recorded as taken" / "… skipped"), so the action has
clear feedback. The next-intake line refreshes on the invalidated list
query, which the next-due fix above makes accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:02 +02:00
Marc-André Bombeck 96f0b225fd chore(release): v1.8.3 — Insights no longer block the interface
Per-category Insights assessments are served read-only from cache and
generated out of band, the client request is capped, the overnight warm
pass keys on the shown language, a stalled model is briefly remembered,
and Insights/dashboard stream a loading skeleton — so opening and
switching Insights categories stays responsive instead of freezing.
v1.8.3
2026-06-01 09:11:36 +02:00
Marc-André Bombeck 35800becbb fix(insights): make per-category status cards read-only so navigation never blocks on the LLM
Opening an Insights category page froze the whole UI: the per-metric
status card fired a cold, uncapped LLM fetch on mount with no client
abort, so the request hung up to the server's 60s ceiling and the
pending/resolve re-renders thrashed the tab strip and ate touch events.
Timeouts were never negatively cached, so every visit re-blocked.

Fix the class holistically:

- Bound every status GET on the client. The shared use-insight-status
  hook and the inline medication-compliance query now wrap fetch in an
  8s AbortController and stop retrying inline; navigation can no longer
  await an uncapped round-trip.

- Make the status routes read-only. On a cache miss the generator
  enqueues an out-of-band pg-boss job (insight-status-generate) and
  returns a preparing envelope; the client polls a bounded interval
  until the worker warms the cache. Models the dashboard snapshot's
  ready/preparing pattern. The queue is registered in the worker
  allQueues loop + a boss.work handler, with a guard test.

- Fix the nightly warm-pass regression: warm every supported locale
  (the client reads against the active UI locale, not the persisted
  User.locale) and warm on a cached comprehensive outcome too (its
  write still evicts the per-status caches). Add a short-TTL negative
  cache on the timeout path so a transient provider stall no longer
  makes every subsequent visit re-block or re-enqueue.

- Add loading.tsx for the insights segment + the root so any future
  RSC await streams a skeleton instead of blocking the transition.

- Stabilise the advisor regenerate callback so a status-query flip
  can't re-render the memoised tab strip mid-gesture.

New i18n key insights.assessmentPreparing across all six locales.
2026-06-01 08:59:59 +02:00
Marc-André Bombeck 6a603ad587 chore(release): v1.8.2 — one intake row per dose slot
Guarantees a single intake entry per scheduled dose: intake writes snap
to the canonical slot instant and update in place rather than inserting a
sibling row, a re-sync can no longer clear a recorded dose, concurrent
logs converge, and a one-time startup pass collapses existing duplicates.
v1.8.2
2026-06-01 08:33:20 +02:00
Marc-André Bombeck 710902a43c fix(medications): harden the slot upsert against races, downgrades, and double-decrements
Review of the slot-convergence change surfaced gaps that could mis-record
a dose. Fold the write logic into one shared `applyCanonicalSlotWrite`
used by both intake routes and close them:

- Concurrency: catch the unique-key race on insert and converge by
  re-finding the slot row and updating it, instead of returning 500 (and
  dropping the tap) or leaving a duplicate.
- No downgrade: a pending projection echo replayed onto a slot already
  marked taken or skipped is a no-op — it never clears `takenAt`. A
  recorded dose is immutable, so an offline re-sync cannot flip a taken
  dose back to missed.
- Inventory: decrement a medication's remaining count only on a genuine
  pending→taken transition, not on an idempotent re-post.
- Deterministic slot-row selection: prefer an existing actioned row, then
  the pending row, ordered by createdAt then id.
- Snap instant: derive the canonical instant via the same `localHmAsUtc`
  the projector and reminder worker mint with, so the value is identical
  by construction across a DST transition rather than only test-pinned.
- Cleanup job: isolate each slot's collapse in its own try/catch and
  guard the instant-normalisation against a tombstone collision, so one
  slot can no longer dead-letter a user's whole dedup pass.
2026-06-01 08:32:42 +02:00
Marc-André Bombeck 5047eb7a20 fix(medications): collapse pre-existing duplicate dose-slot rows at boot
The write-path convergence stops new duplicate slot rows, but accounts
that already accrued a pending + taken pair for one dose keep an inflated
expected count, a phantom taken marker, and a suppressed "due now" prompt
until the bad rows are cleared.

Add a boot-time, idempotent cleanup queue (mirroring the existing
backfill queues): discover only the users holding a same-slot duplicate,
then per medication snap every live intake to its canonical slot, keep
the most-actioned row per slot (taken over skipped over pending),
normalise its instant, soft-delete the losers, and recompute the
affected compliance rollups. Rows that resolve to no scheduled slot
(PRN / as-needed) are never collapsed. A second pass is a no-op.

Registered in the worker's queue list with a static guard test, after a
prior incident where a queue was built but never registered and silently
never ran.
2026-06-01 08:12:48 +02:00
Marc-André Bombeck 0d7d08f616 fix(medications): converge intake writes onto one row per scheduled slot
A twice-daily medication ended up with two rows for one dose slot: the
projector / reminder worker mints a pending row (source REMINDER) at the
slot's `localHmAsUtc` instant, while a "taken" write from the per-med or
bulk intake route inserted a fresh row (source WEB / API). Because
`source` is part of the `(userId, medicationId, scheduledFor, source)`
unique key — and the written instant could drift by a minute from the
projected one — the two rows coexisted: one taken, one still pending and
overdue. The card then read the slot as taken, counted 100%, and
suppressed the "due now" prompt for a dose the user had not taken.

Resolve every scheduled intake write to the slot's canonical instant
before writing and upsert in place, source-agnostic:

- `resolve-slot-instant.ts` snaps an incoming instant to the nearest
  scheduled slot for that day (enumerated through the canonical
  recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and
  anchors it on the same `localHmAsUtc` the projector and worker use.
  Off-slot and as-needed writes resolve to null and keep insert semantics.
- The per-med and bulk routes look up the existing row at the canonical
  `(userId, medicationId, scheduledFor)` ignoring source and update it in
  place — mirroring the status-toggle route — instead of inserting a
  sibling. The bulk per-entry response gains an additive `updated` status.
- The reminder worker's missed-dose mint now skips a slot that already
  carries an actioned row (taken or skipped) from any source, not only an
  existing pending REMINDER row.

PRN / as-needed writes are unaffected. Regression tests pin the collapse
(exact and one-minute drift), the PRN exemption, and the worker guard.
2026-06-01 08:06:37 +02:00
Marc-André Bombeck 4578078cc1 chore(release): v1.8.1 — multi-time medication compliance
Closes the two surfaces the 1.7.3 multi-time compliance fix did not
reach: the today-tile projection now mints one pending dose per
scheduled time, and the compliance heatmap classifies each dose against
its nearest scheduled time instead of the first window.
v1.8.1
2026-06-01 07:27:50 +02:00
Marc-André Bombeck 7f7bf75e34 fix(medications): classify each time-of-day slot in the compliance heatmap
The History compliance heatmap matched each taken intake event to the
closest schedule by `windowStart` alone, ignoring `timesOfDay`. For a
twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` and `windowStart = "07:00"`, the evening
dose matched the lone 07:00 window and classified as very-late (~12h),
painting the day's cell orange even when both doses were taken on time —
so the heatmap never recovered to green for single-row multi-time
schedules.

Expand every schedule into its effective dose slots (its `timesOfDay`,
else `[windowStart]`) and classify each event against the nearest slot's
window — the slot time anchoring a window that spans the schedule's
`windowEnd − windowStart` duration, overnight spans included. Single-time
schedules collapse to one slot at `windowStart` and stay byte-identical.
The denominator already routed through the canonical engine; only the
numerator's timing classification is corrected.
2026-06-01 07:26:47 +02:00
Marc-André Bombeck fcb517df87 fix(medications): fan out the today-tile projector over every time-of-day
The today-window projector minted a single pending intake row per
schedule, anchored at `windowStart`, dropping a schedule's first-class
`timesOfDay`. A twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` therefore surfaced only its morning
dose in the today tile and the "Erfassen" sheet, and the event-count
compliance rollup recorded one expected dose per day instead of two — a
perfectly adherent user read 50% the moment the second dose was logged,
and deleting then re-adding one dose left the rate stuck at 50%.

Fan the projection out over every `timesOfDay` entry, mirroring the
reminder worker's per-slot mint and dedup against the
`(userId, medicationId, scheduledFor, source)` unique index. Absent
first-class `timesOfDay` the projector still emits one slot at
`windowStart`, byte-stable against the worker's legacy single-window row.

Adds regression coverage across the projector (dashboard summary), the
legacy compliance walker, and the cadence expansion pinning the
single-row twice-daily case at the correct two-dose-per-day shape.
2026-06-01 07:26:26 +02:00
Marc-André Bombeck 869f13612a Merge v1.8.0 Insights release into main
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
v1.8.0
2026-05-31 23:00:08 +02:00
Marc-André Bombeck 3743f91bce chore(release): v1.7.3 — health-record pdf fixes + multi-time compliance v1.7.3 2026-05-31 22:49:16 +02:00
Marc-André Bombeck 013ad5d967 fix(compliance): expand multi-time legacy schedules through the canonical engine
A medication whose schedule row carried multiple timesOfDay plus a legacy
daysOfWeek (and no rrule / rollingIntervalDays) reported 50% compliance for a
fully-adherent 2x/day dose. The expected-slot denominator always ran the
canonical recurrence engine and counted both daily slots, but the numerator
only delegated to the engine for rrule / rolling / one-shot / non-SCHEDULED
rows; a plain-legacy row fell to the local cadence walker, which emitted a
single windowStart slot per day and ignored timesOfDay. 1 taken / 2 expected
read as 50 percent.

Route expandScheduleSlots through the engine for every schedule shape whenever
a medication context is supplied, so the numerator and denominator read the
same source of truth. Harden the context-less legacy walker to iterate
timesOfDay as well, mirroring the engine's legacy branch, so the single
remaining non-engine path stays correct for multi-time rows. Drop the now-dead
expander gate helper.

Add regression guards: a 2x/day legacy row reports 2/2 through
calculateCompliance, and expectedSlotCountForDay agrees with
buildCadenceTimeline on the slot count for an identical schedule.
2026-05-31 22:47:44 +02:00
Marc-André Bombeck aed83b7747 fix(report): keep modules together, label the chart time axis, fix glyph stretch
Four layout defects in the clinical PDF renderer:

- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
  outside it (the trend arrows the summary injects, the superscript-two in
  kg/m2) resolved to the .notdef box at a fallback advance width and stretched
  the surrounding words. Route every drawn string (direct text, wrapped
  paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
  maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
  arrows render as true glyphs is a documented follow-up.

- The sparkline dropped each reading's timestamp and plotted on an index
  axis, so a trend gave no sense of its time span. Plot points by their real
  timestamp and print the first/last dates under the baseline.

- No consistent bottom safe area: scattered y > 240 magic numbers and tables
  with no bottom margin let content run into the footer. Introduce a single
  contentMaxY derived from the page height, footer band, and bottom margin;
  every break check and every autoTable bottom margin refers to it.

- Modules tore mid-block. Add an ensureSpace guard called before each module
  heading and a per-line guard in every free-text loop; give the short
  titration and side-effect tables rowPageBreak: avoid.
2026-05-31 22:47:44 +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é Bombeck 6842b52346 chore(release): v1.8.0 — insights data-driven assessments, embedded targets, explainers 2026-05-31 22:46:18 +02:00
Marc-André Bombeck 67a72b1023 perf(insights): parallelise the nightly per-user assessment warm pass
The warm pass forced the seven per-metric status generators strictly
back to back per user, so a fully configured account paid ~7× one LLM
round-trip serially. Bound the inner pass with a small `p-limit(3)` so
the cards warm a few at a time without saturating the Prisma pool or
fanning out an unbounded number of provider calls. The per-user budget
gate still bounds total cost per night.
2026-05-31 22:43:24 +02:00
Marc-André Bombeck c775861d8c fix(insights): invalidate per-metric assessment caches on measurement edit and delete
The targeted status-insight invalidator only fired on insert and batch
ingest, so editing or deleting a reading left the affected per-metric
assessment cards serving their pre-mutation text for the rest of the day.
Add the fire-and-forget invalidation to the single-measurement PUT
(covering both the old and the new type when an edit re-types a row),
the single-measurement DELETE, and the iOS by-external-ids
reconciliation DELETE (keyed off the distinct types it tombstoned).
2026-05-31 22:43:24 +02:00
Marc-André Bombeck eee6ab2e7e refactor(insights): wire the WMY rollup tier into status snapshots and share generator helpers
Read the MONTH / YEAR rollup tier for the monthly / yearly slices of the
focused single-metric status snapshots (weight, pulse, bmi,
blood-pressure) via `buildGradedSeriesWithRollups`, so the coarse tiers
finally earn their write amplification. BMI has no tier of its own, so it
scales the WEIGHT tier by 1 / heightCm² (`scaleGradedSeries`) — an exact
linear transform. The multi-metric general overview and the tier-less
mood / adherence series keep their bounded in-memory fold.

Fix the coverage-miss path that returned an always-empty monthly / yearly
slice: the bounded ~90-day recent read can never supply those slices, so
on a cold-tier miss the builder now folds the full history in memory
rather than shipping empty coarse arrays when years of raw history exist.
Excludes tombstoned rows from both reads.

Lift the byte-identical `round`, `normalizeSummaryText`,
`normalizeLocale`, `summarizeSeries`, the `{summary}`-envelope parse, and
the cache-row persist block out of the seven generators into
`status-shared.ts`. Drop the per-medication expectation in the compliance
generator: the prompt returns only `{summary}`, so the per-med cards are
server-side placeholders rather than a dead model-authored branch. Remove
the unused `summarizeGradedSeries` export.

Behaviour is unchanged on the in-memory path; the rollup path is exact
where the tier composes (count / min / max / mean) and the payload stays
under the snapshot char cap.
2026-05-31 22:43:24 +02:00
Marc-André Bombeck 75380d5f39 fix(insights): lift explainer popover body to 12px + correct target-summary doc
Two small polish items on the insights category surface.

The desktop explainer popover body rendered at 11px while the mobile
sheet used the larger `text-sm`, an uncomfortable and asymmetric read.
Lift the popover body to `text-xs` (12px) so both surfaces sit on the
type scale.

The `MetricTargetSummary` docstring claimed both the range and the
in-target share are hidden when the route flags `insufficientData`. The
range stays visible — only the share is suppressed. Correct the comment
to match the code.
2026-05-31 22:35:02 +02:00
Marc-André Bombeck 36d5c79526 test(insights): pin explainerMetric props against the en.json explainer keys
`<MetricExplainer>` resolves its copy through a template literal
(`insights.subPage.explainer.${metric}{Title,Body}`), so the static
call-site coverage guard never sees these keys — a typo in an
`explainerMetric` prop would paint the raw key path with no CI signal.

Walk every `explainerMetric="…"` value under `src/app/insights/**` plus
the shared healthkit-metric-page scaffold and assert each resolves a
Title and Body leaf in messages/en.json. Also pin the literal-only prop
contract and a non-empty match count so a future rename can't silently
disable the guard. The locale-integrity test then carries the EN
guarantee to the other five bundles.
2026-05-31 22:35:02 +02:00
Marc-André Bombeck 05097df1ee feat(insights): give every category page a metric explainer + normalise keys to english
Two parts to the explainer surface, landed together because they share
the same i18n block and page props.

Coverage: every routed insights category page now passes an
`explainerMetric` prop, so the heading-side `?` glyph is present on all
37 pages instead of 13. The 24 newly covered metrics — body
composition (body water, bone/fat/muscle/lean mass, fat-free mass,
visceral fat), mobility (flights, walking distance/speed/steadiness,
asymmetry, double-support, step length, walking heart rate),
cardiovascular (pulse-wave velocity, vascular age), hearing
(environmental + headphone levels, loud-sound events), respiratory
rate, daylight, blood glucose, and skin temperature — each gain a one
to two sentence layperson definition grounded in standard clinical
references, shipped in all six locales.

Keys: the explainer key segments were a German/English mix
(`blutdruck`, `puls`, `gewicht`, `schlaf`, `stimmung`, `medikamente`
alongside English ones). Rename them to English to match the
naming-convention ADR — `bloodPressure`, `pulse`, `weight`, `sleep`,
`mood`, `medications` — across every locale, and update the
`explainerMetric` prop values that reference them. These are i18n key
segments, not route slugs, so the slug contract is untouched.
2026-05-31 22:35:02 +02:00
Marc-André Bombeck 8f120cfdef fix(insights): generalise the data-gating threshold across metric kinds
The hard '~5 measurement points' floor did not fit mood (counted in
entries) or medication adherence (counted in scheduled doses). Re-word
the gate as 'a few measurement points/entries/events' and note that the
unit depends on the metric. Also fix the gating-field path: the recency
field lives at dataCoverage.newestMeasurementDaysAgo, not on summary.
2026-05-31 22:32:20 +02:00