Commit Graph
2500 Commits
Author SHA1 Message Date
Marc-André Bombeck 08107fb669 fix(insights): pair sleep-debt and chronotype in one row
Wrap the sleep-debt headline and the chronotype section in a shared
grid on the Sleep view so they stack on narrow viewports and sit
side-by-side from the sm breakpoint up. Both already read the same
sleep-rhythm cache, so the pairing adds no extra fetch; each card keeps
its own internals.
2026-06-20 11:49:39 +02:00
Marc-André Bombeck 979d1ced8d fix(insights): soften Coach FAB glyph to the on-accent token
Drop the pure-white Coach floating-action-button icon for the button's own
text-background on-accent token, so the glyph reads as a calmer mark on the
purple-to-pink gradient instead of a harsh white. Update the colocated
assertion to pin the glyph token.
2026-06-20 11:49:33 +02:00
Marc-André Bombeck a5b3715a46 feat(labs): record qualitative lab results
Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
2026-06-20 11:17:43 +02:00
Marc-André Bombeck e631396af3 Merge feat/fhir-codings into main (v1.18.8)
v1.18.8 — SNOMED category codings on journal Conditions and LOINC + canonical UCUM on lab Observations in the FHIR export. Additive, export-only.
v1.18.8
2026-06-20 10:33:22 +02:00
Marc-André Bombeck a2de09daf3 style: format the new FHIR coding modules 2026-06-20 10:19:44 +02:00
Marc-André Bombeck 7bb4b8d9c5 style: format the demo seed 2026-06-20 10:19:33 +02:00
Marc-André Bombeck 3dd5b3dd5b chore(release): v1.18.8 — coded condition and lab terminology in the export 2026-06-20 09:51:10 +02:00
Marc-André Bombeck 9a29de4b02 feat(fhir): code illness conditions and lab observations
Close the three FHIR R4 coding gaps in the health-record export. Both are
export-layer only — no schema column, no migration, no contract change.

Illness Conditions now carry a broad SNOMED CT category concept per
IllnessType (infectious disease, allergy, injury, mental disorder, autoimmune
disease, chronic disease, with the generic Disease root as the fallback for an
unknown type) instead of the single generic placeholder. The user's own label
stays on code.text, the class on category.text, and the unconfirmed +
patient-reported guard rails are unchanged — the export still asserts no
specific diagnosis.

Lab Observations resolve a curated analyte to a real LOINC coding alongside the
user's code.text for the common biomarkers (HbA1c, lipid panel, ferritin, TSH,
vitamin D, creatinine, eGFR, ALT, AST, CRP, fasting glucose, hemoglobin), with
analyte-key and EN/DE alias normalisation. The canonical UCUM code is stamped
on the value only when the recorded unit matches the mapped canonical symbol;
an unmapped analyte or a non-matching unit keeps the honest text-only code and
display-only unit, never a fabricated code. The resolver takes an optional
canonical-name argument so a future Biomarker-catalog link can take precedence
over the free-text analyte without a further change here.
2026-06-20 09:48:22 +02:00
Marc-André Bombeck 9b8cd819f4 fix(demo): consent the demo user and anchor today's doses to their slots
Mint an active AI-processing consent receipt for the demo user so the
Coach renders its sample conversation without a consent-accept write the
read-only tenant rejects. The receipt is kind `ai_full`, the master grant
the Coach and Insights gates both accept; the read path only checks an
active row of that kind, so a placeholder artefact suffices. Add a
consent_receipts reset to keep the seed idempotent.

Anchor every taken intake's scheduled_for on its canonical dose-slot
instant (08:00 morning, 20:00 evening, Europe/Berlin) with taken_at set
slightly later, so the row is slot-anchored and resolves its slot within
the dashboard's resolution radius. Previously today's morning rows used
scheduled_for = taken_at, which the scheduler reads as an ad-hoc take that
only resolves within a minute of its own instant, leaving the 08:00 slot
reading overdue while the take floated beside it. Today's picture now reads
on-track at any daytime clock, with the evening dose pending and not yet due.
2026-06-20 09:46:43 +02:00
Marc-André Bombeck 69d03c45c4 seed(demo): bake AI insight texts into the demo cache rows
The public demo has no live AI provider, so the Insights, daily-briefing,
and per-metric status surfaces rendered empty or prompted to connect a
provider. Pre-populate the same cache rows a real generation would write
so the demo shows finished, grounded English prose that survives every
re-seed without any outbound call:

- users.insights_cached_text: comprehensive insight + daily briefing
  (paragraph, signals of the day, key findings, trend annotations), with
  insights_cached_at refreshed so the read serves it as current.
- audit_logs: the seven per-metric status cards, stamped with today's
  Berlin day key and a real model name so the cache read treats each as a
  fresh assessment.
- insight_narratives: week and month narratives, AES-256-GCM at rest like
  every other narrative row.

All prose follows the assessment voice: second person, grounded in the
seeded numbers, autonomy-supporting, never diagnostic. English only, the
demo locale. Adds insight_narratives to the cleanup block so the seed
stays idempotent.
2026-06-19 23:49:29 +02:00
Marc-André Bombeck e990c12529 Merge release/v1.18.7 into main
v1.18.7 — dashboard, Coach and insights polish; AI request batching; mobile consistency; restored share-link section with passphrase gate; medication supply freshness; security hardening. Three additive migrations.
v1.18.7
2026-06-19 22:44:21 +02:00
Marc-André Bombeck 90efad09fd chore(git): stop tracking the generated client path
A worktree symlink at src/generated was committed by mistake; on a fresh
checkout it resolves to a self-referential link and breaks prisma generate.
Untrack it and ignore the whole src/generated path, not just its prisma
subdirectory, so the build always materialises a real client there.
2026-06-19 22:27:03 +02:00
Marc-André Bombeck ad163f811b test(medications): point the supply source-guard at the renamed detail file
The inventory summary spec reads the detail-tab source by path to assert
both supply readouts ride summariseSupply; update the literal to the
kebab-case filename.
2026-06-19 22:22:34 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck b3fea2ef59 ci(quality): add a prettier format-check gate
Wire `pnpm format:check` into the Lint/Typecheck/Test job and extend
.prettierignore so prettier never rewrites generator-owned artifacts:
the OpenAPI contract (whose exact bytes the openapi:check job enforces),
the build-emitted service worker, the Prisma client, and the lockfile.
2026-06-19 22:20:11 +02:00
Marc-André Bombeck 68033b2d66 refactor(onboarding): point consumers at the kebab-case component files
Update the onboarding step page, baseline form, and the anamnesis-card
spec to import from the renamed kebab-case files. Export identifiers stay
unchanged.
2026-06-19 22:17:34 +02:00
Marc-André Bombeck 922f770b41 refactor(medications): rename PascalCase component files to kebab-case
Bring the medication card, scheduling, wizard, and detail component
files in line with the kebab-case file-naming convention. Component
export identifiers are unchanged; only filenames and the import paths
that point at them move.
2026-06-19 22:17:13 +02:00
Marc-André Bombeck 7c2762b0a5 docs(changelog): expand the v1.18.7 entry for migrations and contract changes
Record the share-link restore and passphrase gate, the Labs/preventive/
condition settings sections, the AI-request batching and prompt
consolidation, the mobile consistency pass, the medication-supply
freshness fix, the budget-gate hardening, and the three new migrations.
2026-06-19 22:11:47 +02:00
Marc-André Bombeck 9c28016faa chore(openapi): regenerate for the share-link passphrase fields 2026-06-19 22:08:01 +02:00
Marc-André Bombeck ce684cc429 chore(db): renumber the share-link passphrase migration to 0180
The status-batching work claimed no migration, so the passphrase column
takes 0180 and the sequence stays gapless after 0179.
2026-06-19 22:07:46 +02:00
Marc-André Bombeck aa6196a789 Merge wave-sharing-harden: passphrase + QR second factor for share links
Gate the clinician share view behind an auto-generated passphrase carried
in the QR URL fragment and stored only as an HMAC hash. The public view
verifies it in constant time behind a rate limit and sets a short-lived,
path-scoped cookie before rendering; links minted before this change keep
working ungated.
2026-06-19 22:05:49 +02:00
Marc-André Bombeck e057589574 Document the passphrase fields and unlock route in the API contract
Extend the share-link create response with the passphrase, share URL, and
QR deep link, and add the protected flag to the summary. Document the
public POST /api/c/{token}/unlock passphrase-gate route. Regenerate the
committed OpenAPI YAML to match.
2026-06-19 22:03:59 +02:00
Marc-André Bombeck d75a538614 Show the QR, passphrase, and protected badge in the owner UI
On a successful create the Sharing section now renders the QR code (from
the #k= deep link), the passphrase as copyable text, and the link, with a
clear shown-once notice. The active-link list carries a small passphrase
indicator. Add the new strings (QR label, passphrase label, copy buttons,
hints, protected badge, and the public unlock prompt) across all six
locale bundles.
2026-06-19 22:03:53 +02:00
Marc-André Bombeck 5783acacbb Gate the public share view behind the passphrase
A protected share link no longer renders the record until the passphrase
is verified. The public page checks the gate state first (without bumping
access counters), and for a link with a passphrase hash it serves a gate
island unless a valid, token-scoped unlock cookie is present. A legacy
link with a null passphrase hash renders exactly as before.

The unlock route verifies the submitted passphrase with a constant-time
compare against the stored hash, is rate-limited by share-unlock token
and IP before any compare, and on success sets a short-lived (30 min)
httpOnly, SameSite=Strict, Secure cookie scoped to that one token's view
path. Every failure class answers one blunt 401; no-store and the flat
404 for unknown links are preserved. The gate island auto-submits the
#k= fragment (scrubbing it from history first) or prompts for input.
2026-06-19 22:03:46 +02:00
Marc-André Bombeck 1aeb6f7957 Mint a passphrase second factor on share-link create
Every new share link now carries a high-entropy passphrase (80-bit,
Crockford-base32, grouped XXXX-XXXX-XXXX-XXXX). The create response
returns the raw passphrase, the share URL, and a QR deep link EXACTLY
ONCE alongside the token; the server stores only the passphrase's HMAC
hash (the same key as the token hash). The QR deep link carries the
passphrase in the URL fragment (#k=) so it never reaches a server log,
referrer, or access record.

Add the passphrase generator, normaliser, and constant-time verifier;
the short-lived token-scoped unlock-cookie mint/verify; a gate-state
resolver that reports live + passphrase status without bumping access
counters; and the unlock request schema.
2026-06-19 22:03:36 +02:00
Marc-André Bombeck 53d41c190b Add nullable passphrase hash to clinician share links
Introduce a nullable passphrase_hash column on the share-link model so a
link can carry a passphrase second factor. The column is nullable, so
every link created before this change keeps the possession-only contract
and the public view renders for it without a gate.

Migration 0181 hand-authors the ALTER TABLE; the column stores only the
HMAC hash, never the raw passphrase.
2026-06-19 22:03:25 +02:00
Marc-André Bombeck 5e541ebf4f refactor(ai): drop dead status-batch reverse map, consume the budget type
Remove the unused output-key reverse map the batch path never read, and
have the budget table satisfy the AiBudget interface directly so the type
is enforced rather than orphaned.
2026-06-19 21:51:22 +02:00
Marc-André Bombeck 208c607b93 Merge wave-ai-status-batch: batch the per-metric status assessments
Split each per-metric status generator at the provider boundary so the
snapshot build, cache read, and content-hash gate run without the model,
then send one structured request covering every metric that has data and
fan each summary back into the same per-metric cache row. Collapses the
seven specialised status calls per warm cycle into one while leaving the
read path, caching, and single-card behaviour unchanged.
2026-06-19 21:47:46 +02:00
Marc-André Bombeck 07333e17c7 test(insights): type the batch finalize fixture to satisfy lint 2026-06-19 21:46:28 +02:00
Marc-André Bombeck e6d48e2de5 test(insights): cover the batched status generator
Prove one provider call covers every present metric, absent metrics are
omitted from the prompt and never fabricated, an all-served cycle makes no
call (the per-metric hash gate still short-circuits), and the batch degrades
gracefully: an omitted metric or a provider error falls each affected card
back to its single-card path, invalid JSON retries once, and a no-provider
cycle serves each card's precomputed no-key result.
2026-06-19 21:43:59 +02:00
Marc-André Bombeck b3b6720723 feat(insights): batch the seven status assessments into one provider call
Add generateStatusBatchForUser: it prepares the seven specialised status
snapshots once, sends one prompt for a { perMetric } envelope covering only
the metrics with data, and fans each summary back into the same per-metric
cache rows the standalone generators write. One JSON-correction retry guards
the parse; any omitted metric or a failed batch degrades to that card's own
single-call path, so a partial response never goes dark. The nightly warm and
the forced warm route through the batch in production while the injected
per-card seam keeps the cron tests intact. Adds the status-batch prompt
scaffold composed from the shared assessment contract.
2026-06-19 21:41:14 +02:00
Marc-André Bombeck f4812f9af2 Merge wave-sharing-restore: bring back the Freigabe settings section
Restore the clinician share-link section as its own Settings entry next to
the health-record export, rendering through the shared SettingsCard and the
envelope-aware api helpers. The backing routes and the public view were
untouched by the earlier trim, so the owner UI reconnects to them as before.
2026-06-19 21:39:46 +02:00
Marc-André Bombeck 0518b26d08 feat(settings): restore the clinician share-link section
Bring back the Settings → Sharing ("Freigabe") section: the owner mints
a time-boxed, revocable, read-only share link to their health record at
the public /c/[token] view. The backing model, the /api/share-links
lifecycle routes, and the public clinician view were left in place; this
restores the owner Settings surface and re-wires it.

Place the slug directly after gesundheitsakte and before export — sharing
the health record belongs next to the health-record export. The entry is
always available (no module gate), like account and export.

Render the card through the shared SettingsCard primitive so it matches
every sibling section; drop the section's own page title (the shell
supplies the heading and subtitle). Route the list, create, and revoke
calls through the api-fetch helper instead of raw fetch, and align the
touch targets with the mobile sizing contract.
2026-06-19 21:38:40 +02:00
Marc-André Bombeck 52d3e5bc10 refactor(insights): split the remaining status cards at the provider boundary
Apply the prepare -> run -> finalize split to blood-pressure, pulse, bmi,
mood, general, and medication-compliance status, matching weight-status.
Each public generator keeps its signature and one-call behaviour by driving
its prepared card through the shared single-card path; the snapshot build,
cache read, read-only miss, and content-hash gate are now reusable by a
batched caller without changing what each card persists.
2026-06-19 21:36:11 +02:00
Marc-André Bombeck beb0be7452 refactor(insights): split weight-status at the provider boundary
Introduce a prepare -> run -> finalize contract for the per-metric status
cards so the snapshot build and the provider call can be driven separately.
The public generator keeps its signature and one-call-per-metric behaviour;
weight-status now resolves through the shared single-card path. Add the
status-batch AI budget entry sized for one combined seven-metric call.
2026-06-19 21:31:16 +02:00
Marc-André Bombeck 4daa5a428f Merge wave-mobile-settings: SettingsCard primitive + phone tap targets
Replace the hand-rolled Settings card divs across every section with a
shared SettingsCard wrapping the Card primitive, eliminating the sm-vs-md
padding split and the primitive bypass, and raise the Settings touch
controls to a 44px floor that condenses at sm+.
2026-06-19 21:30:35 +02:00
Marc-André Bombeck 407cb40ef7 chore: drop accidentally tracked src/generated worktree symlink 2026-06-19 21:29:08 +02:00
Marc-André Bombeck e0c1601a36 fix(settings): raise account form CTAs to the 44px touch floor
The profile save, add-passkey, change-password trigger, and password
dialog submit ran at 40 px on phones; apply min-h-11 sm:min-h-9.
2026-06-19 21:28:34 +02:00
Marc-André Bombeck d9393a2148 fix(settings): raise primary controls to the 44px touch floor
Apply the canonical min-h-11 sm:min-h-9 (and min-w-11 for icon-only
controls) to the primary Settings controls that sat at 32-40 px on
phones: the AI fallback-chain reorder/remove/save/add icons, provider
form save/test/connect actions, coach-memory forget, avatar replace and
remove, section save/cancel/reset CTAs, the export and import action
rows, the About external links, and the thresholds editor row. Collapse
the injection-sites checkbox grid to one column on phones and stack the
password-change grid below lg.
2026-06-19 21:27:05 +02:00
Marc-André Bombeck 8b3b1e1985 chore(db): renumber the briefing-reroll migration to 0179
Two parallel changes both claimed 0178; the session-expiry indexes keep
0178 and the briefing-reroll column moves to 0179 so the sequence stays
unique and ordered.
2026-06-19 21:25:22 +02:00
Marc-André Bombeck 0b82d109f0 Merge wave-ai-foundation: AI request efficiency, standardization, variety
Centralise per-surface AI budgets and right-size status maxTokens; add
seed support (pinned for reference surfaces, seedless for the briefing);
force JSON on Ollama and Anthropic; reroll only the daily-briefing prose
once a day for variety while findings stay stable; reuse the corrective
retry on the comprehensive path; extract shared prompt contracts as one
source of truth for grounding, tone, and the GLP-1 safety rule; reserve
the coach budget atomically before the call and reconcile burned tokens;
turn the local-AI private-host flag into a host allowlist.
2026-06-19 21:25:04 +02:00
Marc-André Bombeck ea95826415 feat(ai): make the local-host SSRF escape hatch a host allowlist
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` was a binary flag that, when `true`,
opened every private/internal host at once — including the cloud
metadata endpoint and any internal panel. Replace the binary check at
all three sites (write-time validation, local client, test resolver)
with a shared `isLocalAiHostAllowed` helper: a comma-separated host list
permits only those exact hostnames, while the legacy `true` still
permits any private host for backward compatibility. The safeFetch /
isPublicUrl floor is untouched; the allowlist only widens what the
operator deliberately permits. Docs and the SSRF-pin inventory test are
updated accordingly.
2026-06-19 21:20:01 +02:00
Marc-André Bombeck b10d978bce Merge wave-mobile-rest: phone tap targets, shared list-row, scroll-touch
Raise primary touch controls across the non-Settings surfaces to a 44px
floor that condenses at sm+, add a shared ListRow primitive and migrate
the three bespoke list-row mini-cards onto it, and add a scroll-touch
utility for the horizontal scrollers.
2026-06-19 21:19:32 +02:00
Marc-André Bombeck a371678460 test(coach): guard composer controls against the sub-44px floor
Assert the send and stop controls render the `size-11 sm:size-9` tap-
target pattern so a future restyle cannot quietly drop them back to the
36px size the audit flagged. Renders the real component and matches the
emitted class string, the same model as the bottom-nav size test.
2026-06-19 21:18:27 +02:00
Marc-André Bombeck e3071d4f0a fix(mobile): raise primary touch controls to the 44px floor
Sweep the primary touch controls that sat below the 44px tap-target
floor on phones onto the canonical `min-h-11 sm:min-h-9` (text) /
`size-11 sm:size-9` (icon) pattern, condensing back at `sm:` upward:

- Coach composer mic / stop / send icon buttons (were `size-9`).
- Dashboard hero CTA, link and action variants (were `size="sm"`).
- Vorsorge list-view "mark done" button (was `min-h-9`) and its kebab,
  which now uses `size="icon-lg"` so the rendered control is 44px
  rather than the icon variant's 40px.
- Illness list-view "log day" button (was `min-h-9`).
- Medication wizard Step 3 injection-sites disclosure (was `h-9`).

The Vorsorge dashboard-card capture action was raised alongside the
ListRow migration.
2026-06-19 21:18:19 +02:00
Marc-André Bombeck 54a2d19833 feat(ui): add shared ListRow primitive and adopt it for mini-cards
The bordered list-row "mini-card" appeared three times with three
shapes: the Vorsorge dashboard row (`rounded-md p-2.5`), the daily-
briefing rows (`rounded-md p-3`), and the measurement-list mobile row
(bespoke `rounded-lg p-3` div). Introduce `ListRow` (`rounded-lg border
p-3`, `asChild` per the shadcn convention) and migrate all three to it.
The primitive owns only the box shape; each caller keeps its layout,
colour, and interaction classes, so this is structural normalization
with no content or behaviour change.
2026-06-19 21:18:11 +02:00
Marc-André Bombeck d2be72d57a feat(mobile): add scroll-touch momentum utility for horizontal strips
Add a `.scroll-touch` utility that restores iOS WebKit momentum
scrolling (`-webkit-overflow-scrolling: touch`) and contains the
overscroll chain so a flick at the end of a horizontal row does not
rubber-band the whole page. Apply it to the insights tab strip and the
cycle tab list — the two in-app horizontal scrollers that lost inertial
flick under the default `overflow-x-auto`.
2026-06-19 21:18:05 +02:00
Marc-André Bombeck 76bc0f2e1f refactor(settings): compose import/export card shells from SettingsCard
Migrate the ImportCardShell and ExportCardShell containers to
SettingsCard, threading their dynamic outerClassName through cn. The
layout hub-link row keeps its element (a full-row Link, not a content
card).
2026-06-19 21:17:35 +02:00
Marc-André Bombeck 5f2a76ddbc fix(coach): close the budget-gate TOCTOU and count burned tokens
The daily token cap was read before the provider call and recorded only
after a successful reply, so concurrent requests could all read
`spent < cap` and all hit the provider, and an empty/sentinel reply
recorded zero spend despite burning upstream tokens.

Reserve the budget atomically before the call: a single upsert-increment
returns the new total, so concurrent requests serialise on the row and
cannot both pass the cap; over-cap refunds the reservation and surfaces
`coach.budget.exceeded` as an SSE error frame. Reconcile the reservation
to the actual token count immediately after the provider returns —
before any empty/sentinel short-circuit — so burned tokens are always
recorded. An integration test proves two concurrent requests cannot both
exceed the cap and that an empty reply still records its cost.
2026-06-19 21:16:43 +02:00
Marc-André Bombeck 96f2b47545 refactor(settings): compose multi-card sections from SettingsCard
Migrate the remaining hand-rolled card containers in the account,
about, medications, api, mood, notification-status, and advanced
sections to SettingsCard, preserving each card's heading, body, and
test hooks. Leaves the passkey list-row (rounded-lg) for the list-row
token track.
2026-06-19 21:16:10 +02:00