Commit Graph
146 Commits
Author SHA1 Message Date
Marc-André Bombeck b042fcfc27 Merge w10/ingest into release/v1.18.10
Capture Withings sleep vitals (HR/respiratory/SpO2/sleep-HRV/score) via the sleep
summary; collapse overlapping BP sources on the cold path for web/iOS parity; add
a STRESS_SCORE source-priority ladder; add a soft-delete-aware measurements index
and cap the all-time cold scan.
2026-06-20 16:53:35 +02:00
Marc-André Bombeck 3fb1f3989c perf(measurements): cover soft-delete reads and cap the all-time live scan
Add a covering partial index over (user_id, type, measured_at DESC) WHERE
deleted_at IS NULL (migration 0183). The hot reads all filter deleted_at and
order measured_at DESC, but the base index is ASC and excludes deleted_at, so
every type not served by the two existing partial indexes pays a heap re-check
of deleted_at over its partition. The covering partial removes the re-check for
the DISTINCT-ON-latest, list and series reads.

Cap the cold-fallback all-time aggregate in summaries-slice at a 15-year
window. It sits far beyond any real history, so it never changes a real
all-time count/min/max/mean; it exists only so a coverage-miss cold read can't
trigger an unbounded full-partition scan.
2026-06-20 16:52:19 +02:00
Marc-André Bombeck 3d039d5220 feat(labs): add local-OCR text mode for image-blind AI providers
Add an opt-in in-browser OCR path so a user whose provider cannot read
images (ChatGPT-OAuth/Codex, a text-only model) can still scan a lab
report. The capability probe gains a `mode` field: `vision` when the
provider reads the image directly, `text` when local OCR is enabled and
any provider is configured, or null. The extract route grows a text
branch that accepts the in-browser-OCR'd text as JSON and structures it
through the existing schema + review/commit flow; the raw image never
reaches the server in text mode. New `labsLocalOcrEnabled` user column
(migration 0184, default off) gates it, with a dedicated PATCH endpoint.
2026-06-20 16:50:06 +02:00
Marc-André Bombeck f46e9d3bb8 feat(coach): surface per-turn token usage on the chat stream
Carry an additive usage envelope on the SSE done frame
({ totalTokens, model }) and persist tokensUsed + model onto
coach_messages (migration 0182, nullable, no backfill) so the
per-message token footer survives a conversation reload. Add an
optional reasoning frame the client can render inside the thinking
disclosure when a reasoning-capable provider emits a summary; providers
without reasoning emit none. Extend CoachMessageDTO and regenerate the
OpenAPI contract. Server-authoritative: clients display the count, never
recompute it.
2026-06-20 11:58:58 +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 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 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 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 853a1cd301 feat(insights): vary daily-briefing prose on unchanged data
The content-hash gate re-stamps the prior text whenever the snapshot is
unchanged, so the "daily" briefing read byte-stable day-over-day. Decouple
phrasing freshness from data freshness for the dailyBriefing.paragraph
alone: on a hash-unchanged hit, re-roll just the paragraph once per
calendar day at a higher, seedless temperature, keeping every structured
finding verbatim from the cached payload.

A new `insightsBriefingRerollDate` column gates it to at most one extra
call per day, and a re-roll miss still stamps the day so a failing
provider cannot be re-driven on every page-open. The hash gate stays
fully intact for the status cards (reference assessments — monotony is
fine).
2026-06-19 21:05:19 +02:00
Marc-André Bombeck b3d303217c perf(db): index session and challenge expiry columns
The sliding-30-day session reaper and the passkey-challenge sweep both
filter on expires_at, but neither sessions nor auth_challenges indexed
that column, so the cleanup degraded to a sequential scan on a busy
instance. Every sibling expiry table already indexes its expiry column;
add the two missing indexes via migration 0178.
2026-06-19 20:52:31 +02:00
Marc-André Bombeck ddbb25d5d4 fix(coach): cap proactive nudges by the persisted conversation, not push success
The proactive-nudge frequency gate only counted delivered push attempts. A
user with no push channel never wrote a delivered row, so the cap was inert
for them: every cron tick that hit a persistent trigger minted a fresh nudge
conversation in the rail.

Count a recently persisted nudge conversation toward the same rolling window
so the once-per-window contract holds regardless of push outcome. Add a
covering index on coach messages for the assistant-message scans the FAB poll
and the gate both run, and drop the unused providerType parameter from the
persistence helper now that the tag is fixed.
2026-06-18 22:41:47 +02:00
Marc-André Bombeck c941ab24f4 fix(migrations): renumber coach last-seen migration to 0176
The disclaimer-preference migration already claims 0175 in this release;
move the coach last-seen stamp to 0176 so both apply uniquely and in
order.
2026-06-18 22:05:17 +02:00
Marc-André Bombeck 6614e0646f merge: Coach v1.18.6 nudge and conversation continuity
Add the Coach proactive nudge-status surface and last-seen tracking
(migration renumbered to 0176) plus the seen endpoint.
2026-06-18 22:04:31 +02:00
Marc-André Bombeck 0a8ccaa30c feat(onboarding): one-time medical-disclaimer acknowledgment
Replace the per-page and per-chart disclaimer banners with a single
acknowledgment at the start of onboarding: HealthLog is a private tracking
tool, not a clinical assessment or diagnosis; talk to a doctor if worried.

- record the acknowledgment per user (disclaimer_acknowledged_at/_version,
  migration 0175) mirroring the research-mode acknowledgment precedent
- gate the welcome "Get started" on the checkbox; expose the timestamp on
  /api/auth/me so a returning user is never re-asked
- keep the full legal text reachable on the public privacy page
2026-06-18 21:37:13 +02:00
Marc-André Bombeck 3a3f88a80c feat(coach): mark an unread proactive nudge on the Coach launcher
Add a discreet unread dot to the Coach FAB when the Coach has something
the user has not opened yet, and clear it when they open the Coach.

The signal is server-authoritative: a new coach_last_seen_at column on
users records the last time the Coach was opened, and the launcher's
status endpoint reports unread when the newest assistant message is
newer than that stamp — so the dot works even with no push channel and
clears across every device, web and iOS. Opening the Coach (FAB or the
/coach page) stamps the column via a new /api/insights/coach/seen
endpoint; the launcher keeps a local stamp only for an instant paint.

The dot is a calm cyan, not an alarming red, per the card-status rule.
2026-06-18 21:19:27 +02:00
Marc-André Bombeck 2c87a93827 feat(glucose): add a diabetes opt-in preference
Introduce an explicit, user-declared "I have diabetes / clinician glucose
targets" preference (`User.hasDiabetes`, migration 0174, defaulted false) plus
its GET/PATCH endpoint at `/api/auth/me/diabetes`. The flag selects which
glucose target band a reading is judged against; it is never inferred from a
value and never asserts a diagnosis. Mirrors the existing per-user-scalar
preference pattern: 60/min rate limit, Zod validation, audit-logged,
field-by-field write. OpenAPI regenerated for the new route.
2026-06-18 20:17:13 +02:00
Marc-André Bombeck 59e7b33285 feat(onboarding): add resumable module-tour progress field + API
Add nullable onboarding_tour_progress_json to users (migration 0173),
a Zod-validated progress shape, and extend POST /api/onboarding/tour to
accept a mid-tour resume checkpoint and clear it on replay. Surface the
resume point on GET /api/auth/me and register the contract in OpenAPI so
the native client mirrors it.
2026-06-18 19:27:28 +02:00
Marc-André Bombeck 7451e886f1 fix(vorsorge,coach): align reminders + coach chrome with the gold standard
Twice-daily BP protocol (RRULE BYHOUR=7,19) now fires at every clock
hour: byHourTimesOfDay expands BYHOUR into the engine's timesOfDay so
the label, RRULE, and engine output agree instead of collapsing to one
nudge at notifyHour.

Vorsorge surface:
- move create/edit into a ResponsiveSheet with a pinned Cancel/Save
  footer, matching Labs/Illness/Medications
- wire the unused PATCH support: Edit affordance opens the form
  pre-filled; an enable/disable Switch drives the enabled flag
- translate COACH-origin labels (the row stores an i18n key, not prose)
  and render a neutral Coach provenance badge + an until-<date> line
- surface origin + endsOn on the hook type
- pull-to-refresh + always-on rationale documented on the nav entry
- NativeSelect-vs-Radix choice documented as deliberate

Coach:
- expired COACH course-window reminders self-clean: the cron tick
  soft-deletes finished finite cadences instead of leaving dead rows
- accept-dedup gains a partial unique index (user, metric) where
  origin=COACH and not deleted, with a P2002 catch in the route
- per-user rate-limit bucket on the suggestion-action endpoint
- SUGGEST-REMINDER parser keeps prose written after the block
- document POST /api/coach/reminder-suggestions in OpenAPI

Coach chrome:
- HistoryRail hideHeading prop drops the duplicate Conversations
  heading on the page surface
- align the rail-tray strip inset to px-3 sm:px-4 so the dividers line
  up; reword the FAB inset comment

satisfyReminder closes the forward-only TOCTOU via a conditional
updateMany (count===0 no-op). Migration headers corrected to v1.18.1.
2026-06-16 23:11:08 +02:00
Marc-André Bombeck 016eb12b42 feat(schema): add the v1.18.1 data foundation
Three additive migrations the v1.18.1 feature surfaces build on:
reminder engine fields (endsOn + origin, so coach-cadence and preventive
care produce into one reminder table), a user-scoped biomarker catalog
(name, unit, bounds, optional encrypted context, panel) with a nullable
LabResult.biomarkerId link, and the condition-journal model (IllnessEpisode
plus a CycleDayLog-shaped symptom timeline). No code consumes these yet;
the surfaces ship gated behind the module system.
2026-06-16 20:36:13 +02:00
Marc-André Bombeck 5d55347dd0 feat(modules): add operator-level server-wide module availability
Second layer of the module enable/disable model: an operator can turn a
module off for the whole server, and an operator-disabled module is off
for every account regardless of personal preference — mirroring the
coach master flag above the per-user opt-out.

Storage is a DISABLED allowlist on AppSettings.moduleAvailabilityJson
(absent/empty/true = available, explicit false = disabled), resolved by
getOperatorModuleAvailability(). The gate now resolves every toggleable
module as operatorAvailable(key) AND userEnabled(key); cycle and coach
keep their existing delegation but are still AND-ed with operator
availability. Core domains are never disableable.

GET/PATCH /api/admin/settings/module-availability is cookie-only
(requireAdmin); a strict per-module-key schema rejects core domains and
unknown keys at the boundary.
2026-06-15 22:44:35 +02:00
Marc-André Bombeck 6116d92ff5 feat(modules): add module_preferences_json disabled-allowlist column
Additive, nullable JSONB on users carrying a default-on disabled
allowlist for the optional (secondary) domains. NULL / empty / a key
set to true all read as enabled; a key set to false disables that
module. No backfill — every existing account reads all-on.

Core domains (weight, blood pressure, pulse, medications) are never
written here and have no toggle. cycle and coach keep their existing
source of truth and the column does not own them.
2026-06-15 20:53:46 +02:00
Marc-André Bombeck 78f0e067b5 fix(migrations): reference the mapped measurement_type enum in the reminders table
The generated 0162 migration typed the measurement_reminders.measurement_type
column as the Prisma enum name "MeasurementType" rather than its @@map'd
Postgres type "measurement_type", so a fresh migrate deploy failed with
'type "MeasurementType" does not exist'. Unit typecheck passed (the generated
client is unaffected); only a real-Postgres deploy surfaces it. Verified the
full 0001-0166 chain applies clean against Postgres 16.
2026-06-15 01:49:28 +02:00
Marc-André Bombeck 2736e23092 merge(oura): real sleep timeline, SpO2, sleep score, temperature deviation, contributors + reconcile
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	prisma/schema.prisma
#	src/components/measurements/measurement-list-meta.ts
#	src/lib/__tests__/measurement-type-enum-coverage.test.ts
#	src/lib/insights/chart-tokens.ts
#	src/lib/measurements/__tests__/apple-health-mapping.test.ts
#	src/lib/measurements/categories.ts
#	src/lib/personal-records/pr-direction.ts
#	src/lib/validations/measurement.ts
2026-06-15 00:17:51 +02:00
Marc-André Bombeck 3cdbab908c feat(measurements): add Sleep Score and body-temperature deviation types
Two additive measurement_type values for the Oura coverage completion:

  - SLEEP_SCORE — the headline 0-100 sleep-quality score (unit score),
    kept apart from the WHOOP sleep sub-scores so two vendors' headline
    numbers never share a bucket.
  - BODY_TEMPERATURE_DEVIATION — a signed nightly body-temperature offset
    from the personal baseline (celsius). It is a deviation, not an
    absolute reading, so it cannot share the body / skin / wrist
    temperature buckets.

Migration 0165 extends the enum (additive, no backfill). Register both in
the validation enum, units, plausibility ranges, category overlay, list-UI
label/icon/colour maps, chart-token allowlist, and the PR-direction switch
(both resolve to no record direction). Both are descriptive signals, not
clinical vitals, so they stay out of the doctor-report PDF. Localise the
two list labels across all six locales and regenerate the OpenAPI contract.
2026-06-15 00:09:33 +02:00
Marc-André Bombeck fd39580ee6 feat(measurements): add ANS_CHARGE and CARDIO_LOAD measurement types
Polar Nightly Recharge and Training Load Pro carry two native signals with
no existing home: the HRV-based autonomic-nervous-system charge (distinct
from the 1-6 recovery band that maps to RECOVERY_SCORE) and the device-native
cardio-load strain figure (distinct from WHOOP DAY_STRAIN and the COMPUTED
STRAIN_SCORE). Add both to the canonical enum (migration 0166) and wire them
through the validation enum, canonical units, plausibility ranges, category
cluster, chart-token allowlist, PR-direction resolver, and the measurement-
list label/icon/colour maps, with labels across all six locales. Both are
device-derived composites, so they read no PR direction and stay out of the
clinical vitals PDF, matching the WHOOP-native score classes.

Regenerate the OpenAPI contract for the two new enum members.
2026-06-15 00:07:30 +02:00
Marc-André Bombeck 1c3316112c chore(migrations): renumber onboarding-goals to 0164 for monotonic order 2026-06-14 23:22:52 +02:00
Marc-André Bombeck 1e28f9cfb8 merge(onboarding): persist goals to seed the dashboard, add anamnesis step 2026-06-14 23:22:42 +02:00
Marc-André Bombeck 5b756f01f2 feat(onboarding): add User.onboardingGoals for goal persistence
The goals step let the user toggle goal slugs but both Skip and Next
discarded the selection — the picker was decorative. Add the
onboarding_goals text array the earlier dead code already referenced so
the selection can persist and seed dashboard tiles + reminder
suggestions. Not a clinical target store; targets stay fully derived
from height/age/gender in /api/insights/targets. Empty array = no
preference; purely-additive migration with an empty-array default, no
backfill.
2026-06-14 23:21:39 +02:00
Marc-André Bombeck 80f3cd8780 chore(migrations): renumber sleep-timeline backfill to 0163 for monotonic order 2026-06-14 23:20:54 +02:00
Marc-André Bombeck ed50d10556 merge(sleep): reconstruct WHOOP per-stage timeline, fix Withings/Fitbit stamping 2026-06-14 23:20:53 +02:00
Marc-André Bombeck ddfed17fa3 feat(sleep): backfill existing WHOOP + Withings sleep rows after the stamp fix
The WHOOP timeline reconstruction and the Withings END-stamp change the
stored measuredAt (and, for WHOOP, the externalId), so existing rows
keep the old broken shape until re-synced. An in-place update collides
with the (userId, type, measuredAt, source, sleepStage) unique index, so
the backfill deletes the affected SLEEP_DURATION rows for the source and
re-syncs from scratch; the re-sync re-folds the rollup tier in its tail.

Add a boot-time pg-boss one-shot per connection, modelled on the WHOOP /
Fitbit backfills: discovery enqueues one job per (user, provider) where
sleep_timeline_backfill_at is null, the per-user pass deletes + re-syncs
and stamps the marker, and the pass is idempotent across reboots. The
queue is registered in allQueues, wired to a handler, and enqueued at
boot.
2026-06-14 23:16:33 +02:00
Marc-André Bombeck 355fa9a670 feat(schema): add per-user Polar/Oura credentials, measurement reminders, lab results
Migration 0162 lands four additive surfaces for v1.17.1, all forward-only and
non-destructive.

- users: Polar and Oura BYO-app client id/secret, encrypted at app level and
  stored as text, mirroring the existing WHOOP/Fitbit credential columns 1:1.
  Each self-hoster registers their own Polar AccessLink / Oura Cloud app and
  pastes the client id/secret into Settings before the OAuth connect flow.
- devices: live_activity_push_token for the iOS Live Activity update/end APNs
  channel. The client owns the ActivityKit lifecycle; the server only stores
  the most recent push token so the worker can address the update.
- measurement_reminders: preventive-care ("Vorsorge") reminders. Cadence is a
  rolling interval_days or an RFC-5545 rrule, so the recurrence engine drives
  the server-authoritative next_due_at. The completion target is an optional
  measurement_type (auto-resolved when a matching reading lands) or a free-text
  label that resolves on a manual check-off. Soft-deleted.
- lab_results: minimal structured lab store — one analyte per row, optional
  panel grouping and reference range, optional encrypted note — so a returned
  blood panel has somewhere to land alongside the Vorsorge reminder.

No enum change: the Polar/Oura/Nightscout measurement sources already landed in
migration 0160.
2026-06-14 23:04:48 +02:00
Marc-André Bombeck 2d5b15c067 Drop unused polar refresh-token column 2026-06-14 21:23:56 +02:00
Marc-André Bombeck cf76e6f5ea Make migration 0161 re-runnable with IF NOT EXISTS
The reorder_lead_days column add used a bare ADD COLUMN; match 0160's
idempotent guard so a re-run is safe. Schema unchanged, SQL-only.
2026-06-14 20:58:49 +02:00
Marc-André Bombeck 08aa655be4 feat(medications): make low-stock alert reorder-lead-time aware
The low-stock alert fired when the projected runway dropped below the
user threshold (default 7 days). For a sparse cadence — a weekly
injection's 7-day floor is barely one dose-interval — the warning landed
with about one dose left, too late to reorder before the supply ran out.

Add an optional per-medication reorder lead time plus a user-level
default (0–60 days, default 10) and widen the alert trigger to
max(lowStockRunwayDays, leadDays + cadenceIntervalDays), so the warning
lands before the last dose for any cadence. Because the trigger is a
max() over the user floor it never shrinks anyone's current threshold; a
daily med with no lead keeps its prior boundary.

The push body and the card notice surface concrete dates — supply runs
out around <date>, reorder by <date> — with a last-dose informational
state and the days/units fallback when supply data is thin. Retire the
stale fixed 4-dose count threshold on the GLP-1 details endpoint and
route its card lowStock flag through the same runway evaluation so the
card and the daily cron agree. Surface the lead default in the low-stock
settings card with progressive disclosure; a single medication can
override it.

Additive schema: nullable Medication.reorderLeadDays (migration 0161, no
backfill) and notificationPrefs.medication.reorderLeadDays. OpenAPI
regenerated.
2026-06-14 20:39:06 +02:00
Marc-André Bombeck 524626d2bd feat(sources): add Nightscout, Polar, Oura schema foundation
Extend MeasurementSource with NIGHTSCOUT, POLAR, OURA and add the
per-user encrypted credential columns each source needs: a Nightscout
base URL + token plus a private-host opt-in flag, and Polar/Oura OAuth
tokens mirroring the WHOOP columns. Migration 0160 is additive only —
new enum values, nullable columns, one boolean default false.

Register the three sources across the read enum, the integration-status
key set, and the source-priority ladders.
2026-06-14 18:47:14 +02:00
Marc-André Bombeck 32d275b4aa Harden consent receipts and document iOS series + ai-provider contract
Document GET /api/measurements/series and GET /api/user/ai-provider in the
OpenAPI registry — both are consumed by the iOS client (charts and Coach
visibility gating) but were absent from the spec.

Break migration 0159's active-receipt dedup on (created_at, id) so an exact
created_at tie still leaves one survivor, keeping the unique index build from
aborting 23505 on affected tenants.

Catch P2002 in createReceipt: two concurrent first grants both reach create,
the loser re-reads the winning active receipt and returns it instead of a 500.
2026-06-14 17:42:00 +02:00
Marc-André Bombeck 555240cd28 fix(consent): close the receipt mint race, rate-limit, and share the back link
Mint the web AI-consent receipt inside a serializable transaction with an
in-transaction recheck, backed by a partial unique index over one active
receipt per user and kind, so two concurrent mounts can no longer leave two
active grants; a duplicate maps to a no-op mint. Supersede an active row
against the server clock rather than the client-supplied timestamp so a
backdated grant cannot invert the audit chain, and revoke through a single
atomic update. Rate-limit the consent endpoints per user. Extract the
hand-rolled sub-page back link into one component and adopt it across the
four pages that duplicated it.
2026-06-14 17:26:36 +02:00
Marc-André Bombeck db770738bf Harden consent supersede against backdated grants and dup rows
Two follow-ups to the active-receipt uniqueness work:

- Migration 0159 now revokes all-but-the-newest active row per
  (user_id, kind) before building the partial unique index. Without the
  pre-flight dedup the CREATE UNIQUE INDEX would abort on any tenant that
  already carries duplicate active grants — exactly the tenants the fix
  targets — and fail the deploy.
- createReceipt stamps the superseded row's revoked_at with a server
  clock instead of the client-supplied signedAt. A backdated signedAt
  would otherwise mark the prior row revoked before its own signed_at,
  inverting the audit chain. The new row keeps the client signedAt.
2026-06-14 17:25:39 +02:00
Marc-André Bombeck 6a173a89dd Enforce one active consent receipt per kind atomically
Two concurrent web-grant heals could each pass the read-then-create gap
and insert a duplicate active ai_full receipt; the revoke path then had
to chase one row at a time. Wrap the web-grant mint in a Serializable
transaction with an in-transaction re-check, and add migration 0159 — a
partial unique index over (user_id, kind) WHERE revoked_at IS NULL — as
the structural backstop. createReceipt now supersedes any active row of
the same kind before appending, and revokeLatest flips revoked_at via a
single atomic updateMany, so the append-only audit chain is preserved
while at most one active row ever exists.
2026-06-14 17:21:30 +02:00
Marc-André Bombeck 2cb84c17e9 Add per-resource cursors, fetch-by-id, and full backfill to WHOOP sync
Replace the single shared last-synced cursor with a per-resource cursor map
on the WHOOP connection. Recovery, sleep, workout, and cycle each advance only
their own cursor on a successful fetch+upsert, so a slow or 403-ing collection
never drags the incremental window forward for its siblings and its own
late-arriving records stay inside the overlap. Legacy connections fall back to
the shared last-synced instant, so no historical state is lost.

Add single-record fetch-by-id client routes and per-resource by-id syncs. The
update webhook now carries the resource id through the queue payload, so the
worker resolves and upserts that one record immediately rather than re-walking
the whole collection on the next overlap tick.

Anchor a full sync at a fixed deep-history point instead of returning no lower
bound, so a backfill covers the multi-year span an incremental tick would skip
and a stale cursor cannot silently narrow it.
2026-06-14 17:19:09 +02:00
Marc-André Bombeck 609d248252 feat(cycle): choose cervix as the symptothermal secondary symptom
Let a user pick the Sensiplan double-check's second indicator — cervical
mucus (default) or cervix observation — mirroring the published NFP
standard where the temperature primary sign pairs with one chosen
secondary sign.

- `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which
  second sign the engine confirms against. Casual users keep temperature
  + mucus untouched; the choice lives behind the advanced cycle settings.
- `CycleDayLog` gains the three standard cervix signs — position
  (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable
  columns (migration 0157), wired through Zod, the day-log upsert, the
  DTO, the sync feed, backup/restore and OpenAPI. Additive and
  back-compatible: existing profiles backfill MUCUS, existing day-logs
  keep NULL cervix signs, iOS inherits the fields.
- The engine's `confirmSymptothermal` takes the secondary-symptom choice;
  on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix
  day followed by three closed days — the cervix-closure rule) instead of
  the mucus peak. The double-check still requires BOTH the temperature
  shift AND the secondary peak: a single cervix sign never confirms, and
  the still-learning gate and conservative posture are unchanged.

Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula &
Frank-Herrmann standard.
2026-06-14 15:23:26 +02:00
Marc-André Bombeck 4bfe9f11d7 feat(cycle): exclude disturbed temperatures from the symptothermal engine
A fever, illness, alcohol or a late/short-sleep reading can fake or mask
a temperature rise. Add a per-day `temperatureExcluded` flag on
CycleDayLog (migration 0156): when set, the engine drops the reading from
the cover-line baseline AND the shift evaluation, so a spike neither
fabricates nor masks a rise (Sensiplan: the cover line is drawn over the
last six unbracketed values).

Wired end to end — schema + Prisma, the engine's `detectTempShift`
filter, the Zod input/patch schemas, the day-log upsert (partial merge +
change detection), the read DTO, the calendar/insights/coach selects, the
backup round-trip, and the OpenAPI contract (a new boolean on the day-log
and calendar-day DTOs for the iOS client). The log-day sheet gains a
"mark reading as disturbed" toggle that appears once a temperature is
entered, translated across all six locales.

Citation: de.wikipedia "Symptothermale Methode"; myNFP "Temperaturkurve
auswerten" — Störfaktor bracketing.
2026-06-14 15:04:25 +02:00
Marc-André Bombeck bb59018ac9 fix(mood): deactivate the sleep-quality factor in the curated catalog
The default-tag curation deactivated the redundant emotion and day-type
tags but left factor_sleep_quality active. A manual 1-5 sleep rating
duplicates the measured sleep-tracking surface, so the factor leaves the
seeded set alongside the rest, with work and sadness remaining the RATED
factors. Reference-data only; historical entries keep resolving since the
read joins the catalog by id, not is_active.
2026-06-14 10:29:22 +02:00
Marc-André Bombeck 7b0688b37e feat(mood): curate default tag catalog
Deactivate the redundant default tags happy, excited, hydrated
("Wasser getrunken") and music; re-activate overtime in the work
category (0126 had retired it); add a new default work tag praise
("Lob" / "Praise", Lucide ThumbsUp) with i18n across all six locales.

factor_sleep_quality stays a RATED factor: its removal was premised on
a dedicated sleep-quality slider that does not exist in the capture
surface, so dropping it would lose the signal with no replacement.

Reference-data only — is_active flips plus an idempotent keyed upsert,
no schema change, no per-user data touched; historical tag links keep
resolving their labels.
2026-06-14 10:14:02 +02:00
Marc-André Bombeck f4dec8c28f feat(medications): fractional dosing via decimal units per dose (#316)
A dose can now consume a fraction of a unit — a half, third or quarter
tablet for a split pill. The wizard offers a curated fraction set beside
the whole numbers (the most error-resistant input); units-per-dose and the
per-container counts widen from Int to Decimal, so a 30-tablet pack dosed
at a half reads 29.5 after one dose and the days-left projection follows.
The consumption ledger stamps and refunds the exact fraction, and existing
whole-number doses are untouched. OpenAPI regenerated — the three unit
fields move from integer to number — and the units-per-dose hint is
updated across the six locales.

Also anchors a first-use date relative to now in an inventory route test
that had aged past its 30-day in-use window.
2026-06-13 16:07:50 +02:00
Marc-André Bombeck 3943dcfb72 feat(medications): as-needed medications log without a schedule
A medication can declare itself as-needed: no schedules, never due,
never reminded, excluded from every compliance rate — but intakes log
ad-hoc, consume inventory exactly like scheduled doses, and the
history stays. The wizard offers it as a cadence choice and round-
trips it on edit; card, table and detail show a calm marker where the
next dose normally sits, the take-all dialog never collects one, and
the supply runway stays unknowable by construction. (#316)
2026-06-12 22:56:03 +02:00
Marc-André Bombeck 058d1d7a69 feat(mood): tags get groups, icons and a home of their own
/settings/mood manages the whole tag vocabulary: own groups (encrypted
labels, twelve per account), custom tags with a searchable icon picker
over a 75-icon catalog, hide and unhide of catalogue tags, archive
semantics that never touch historical entries (hard delete only behind
an explicit second confirm), and drag-or-arrow ordering of groups and
tags shared by the picker. One GET serves the fully ordered tree —
groups, placements, hidden and archived state — so the entry form, the
settings section and a native client all read the same resolved layout.
The picker finally renders custom tag labels (they were ignored since
v1.13.0) and offers inline tag creation per group. The mood-tag API
enters the OpenAPI contract for the first time, and the key-rotation
script now covers both encrypted tag and group labels — custom tag
labels had been missing from it since v1.13.0.
2026-06-12 22:03:01 +02:00
Marc-André Bombeck cac9a32756 feat(medications): low-stock notifications with a runway threshold
A daily job projects each tracked medication's remaining supply against
its schedules and notifies through the normal channel cascade when the
runway falls below the user's threshold (default seven days, 1-60 or
off, set beside the reminder thresholds). One notification per
crossing: a stamp on the medication row re-arms on refill or threshold
change, and a failed dispatch leaves it clear for the next day's retry.
The GLP-1 card's fixed four-dose flag stays as the locked iOS read
contract; the engine covers it like any other medication.
2026-06-12 21:04:09 +02:00