Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.
- Register `illness` as a born-gated (opt-in / default-off) module. The
gate resolver flips the disabled-allowlist default to off for born-gated
keys, so the surface stays dark until the account opts in from the
Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
userId from auth, builds data field-by-field, and gates the module.
Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
the day-log upsert, the gate enforcement, and the born-gated resolver.
The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).
Maintenance patch clearing two transitive security advisories. Bump the
package version, record the changelog entry, and regenerate the OpenAPI
version line. No functional changes.
The DoctorReportDialog + its /api/doctor-report, /pdf and /availability routes
were referenced only by their own tests — the health-record (Gesundheitsakte)
export is the live surface. Remove the dead dialog, routes, their tests, and
the now-stale docs page; the shared doctor-report-data aggregator stays (the
health-record export uses it). The route-gate inventory exempt list is updated
accordingly.
Add the resolved `modules` map to ProfileResponse (shared ModuleMap
schema) so the spec matches the alias payload, and add a reusable
module-disabled 403 response documenting `meta.errorCode = module.disabled`
plus `meta.module` (the disabled key). Wire that 403 onto the module-gated
sleep night/rhythm routes, which emit it via requireModuleEnabled.
Regenerate docs/api/openapi.yaml; openapi:check is in sync.
Register the four /api/insights/chat surfaces in the OpenAPI contract:
list (cursor-paginated), streaming send (SSE), detail (messages decrypted
server-side), and delete. Adds CoachConversation / CoachConversationsPage /
CoachConversationDetail / CoachMessage / CoachProvenance / CoachChatRequest
schemas and regenerates docs/api/openapi.yaml. Additive; the endpoints
themselves are unchanged.
GET /api/auth/me now returns a resolved modules map for every toggleable
module (cycle/coach reflect their delegated state). New PATCH/GET
/api/auth/me/modules read and update the preferences: the body is strict
over the toggleable key set so a core-domain or unknown key is a 422, the
merge is field-by-field (no mass assignment), and userId is narrowed from
the session. Documented in the OpenAPI registry and yaml.
The disable-coach gate-inventory allowlist records the module gate's
intentional coach delegation read.
Document the new Generate keys button in notifications.md as the easiest
Web Push setup path, demoting the CLI to an alternative. Note that
clientManaged medication-reminder suppression is an iOS-app-only contract
so a PWA-only operator does not go hunting for a web toggle.
Add the VAPID env-var names and APNS_KEY_B64 to .env.production.example
(the loader and APNs sender read them, but the example omitted them),
both as commented placeholders, with a note that Web Push needs no Apple
account and that these vars are not in the compose whitelist.
Add a Backup and restore section to getting-started.md: a local pg_dump
one-liner and the load-bearing warning that the ENCRYPTION_KEYS /
ENCRYPTION_KEY must be backed up alongside the dump or the encrypted data
is unrecoverable, cross-linking the off-host backup and key-rotation
runbooks. Add troubleshooting entries for the compose env-var whitelist
and pull_policy: always.
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.
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.
Add an optional hex liveActivityPushToken to device registration. The iOS
client registers the current per-Activity ActivityKit push token so the
server can address a Live Activity update or end push on a
medication-intake mutation, and sends null to clear it when no Activity is
running. Following the medicationDelivery override convention, the field
is only touched when present, so an ordinary re-register keeps the prior
value. Regenerate the OpenAPI contract.
A dedicated /labs page hosts manual entry and a per-analyte view: the
four essentials (biomarker, value, unit, date) stay visible, the
reference range, panel grouping and note sit under an optional section.
Results group by analyte with a small inline trend once an analyte has
two or more readings. The reference-range badge is deliberately neutral
and calm — an out-of-range value reads as a quiet directional marker, not
an alarming red.
Adds the labs section toggle to the health-record export panel, the /labs
nav entries, the /api/labs OpenAPI routes, and the labs copy across all
six locales.
Register the /api/measurement-reminders surface in the OpenAPI table and
regenerate the committed spec so the iOS client consumes the
MeasurementReminderDTO contract from a single source.
A self-hoster migrating from a spreadsheet or a meter export overwhelmingly
has CSV, which neither the JSON nor the Apple Health import covers. Add
`POST /api/import/csv` reusing the existing write loop and rollup re-fold.
The documented header is `type,value,unit,measuredAt[,glucoseContext,notes,
externalId]`, order-independent. measuredAt must carry an explicit ISO-8601
offset — a row without one is skipped rather than guessed — and is bounded by
the shared entry-instant validator. Glucose accepts mmol/L (converted to
canonical mg/dL) and weight accepts lb (converted to kg); any other
non-canonical unit is skipped, never mis-stored. An optional externalId
column makes a re-upload idempotent; without it a re-upload duplicates.
The response carries a per-row status envelope so a 2000-row file with three
bad rows imports 1997 and names the three. `?dryRun=1` validates and previews
the outcome without writing. The parse, validation, and unit conversion live
in a pure module so they are unit-tested without a database.
Settings -> Export & Import gains a third CSV card mirroring the JSON and
Apple Health cards, with upload, paste, preview, and a downloadable example.
The new route is documented in the OpenAPI contract.
WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.
Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.
The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
Add the blood-glucose clinical panel with Nightscout, sleep debt and chronotype,
Polar and Oura over OAuth, reorder-aware medication supply and an invite
deep-link. Every new metric is computed once on the server and read the same way
on the dashboard, the coach, the doctor-report and the companion app, each behind
a still-learning gate. The coach now reasons from the new signals, mood and cycle
entry is one sectioned sheet, and previously-hidden wearable data surfaces.
buildExtras emits bpInTargetCount90 + bpInTargetSpanDays90, but the
registered extras schema (additionalProperties: false) omitted them, so a
strict iOS decoder would reject the dashboard snapshot. Declare both as
nullable integers to match the runtime shape and regenerate the spec.
Hard-coding isSpotEstimate: true mislabels a dense Nightscout CGM stream
(~288 readings/day) as a spot estimate and undersells the data in the
clinical panel and coach. Compute readings-per-day over the covered span
and treat the series as continuous below the spot caveat once it clears a
~hourly bar (24 readings/day); sparse spot fingersticks keep the caveat.
The panel only renders the spot-reading line when isSpotEstimate, and the
OpenAPI field relaxes from a literal-true to a boolean.
Move the low-stock trigger from a bare runway threshold to the larger of that
threshold and the reorder lead time plus one dose interval, so a weekly
medication is flagged before its last dose rather than after — early enough to
reorder. The change only moves the alert earlier; no one's threshold shrinks.
Surface concrete dates (runs out on, reorder by) and a running-low vs last-dose
state through the existing badge and channel, never the card colour. A reorder
lead time is configurable globally with a per-medication override, and the glp1
card flag now runs the same runway decision as the daily check instead of a
stale dose count.
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.
Two daily-use fixes: the last-night hypnogram bar collapsed to its breakdown
for nights whose stages share an end instant, and the dashboard kept prompting
for a dose until a full reload after it was recorded. No breaking changes.
Encode the admin invite QR as a clean /invite/<hlv_token> universal-link
that the iOS app intercepts via the AASA wildcard matcher. Add the
matching web route as a thin, shape-validated redirect onto the existing
register flow, and open the path on the public allowlist so an
unauthenticated scanner reaches it. A malformed token segment is
indistinguishable from no invite — no database hit, no enumeration
oracle. The token is never logged.
Surface a clinical panel beneath the blood-glucose chart: time-in-range on the
consensus bands, the glucose management indicator and estimated A1C, and the
coefficient of variation with its instability flag by default, and the J-index
and the low and high blood-glucose risk indices with a per-context breakdown
behind an advanced disclosure. Below the calibration threshold it shows a calm
learning state, and it always carries the spot-reading caveat. The analytics
read, the dashboard snapshot, the coach and the doctor report all consume one
computed result over a fixed window, so the panel, the assistant and the
printed report cannot disagree.
Compute cumulative sleep debt and the MCTQ chronotype server-side off the
canonical night reconstruction the Sleep Score reads, and render them on
the sleep page: a sleep-debt headline with a calm partial state under the
night threshold, and a chronotype card whose social-jetlag detail and
corrected mid-sleep sit behind an advanced disclosure. Both hold a learning
state on thin data and never assert a total or a band.
Serve the same values to the dashboard summary and a new GET /api/sleep/rhythm
read so the native client renders identical figures. Free vs work nights
default to weekend in the user's timezone until day-type tracking exists.
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.
Read sleep, recovery and blood glucose from a single source on every surface —
dashboard, coach, doctor-report, CSV and FHIR exports, and the companion app —
so the displayed figure agrees wherever it appears. Sleep nights reconstruct
once with source de-duplication; WHOOP-native recovery is canonical and resolves
to one row per night; glucose converts to the user's unit at the point it is
served. Hardens the consent receipt path and deepens WHOOP sync with per-resource
cursors, targeted refresh and full backfill. Several displayed numbers become
correct-but-different — flagged in the changelog.
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.
Replace the divergent UTC-day night reconstructor in the Sleep Score with a
thin adapter over reconstructSleepNights, the shared engine the dashboard,
hypnogram, and doctor surfaces already read. The old path bucketed rows by UTC
calendar day and never deduplicated writers, so a multi-source night (WHOOP +
Apple Health for the same sleep) summed both writers and roughly doubled the
total. The adapter inherits session-clustering, local-wake-day keying, and
multi-source writer dedup, then derives the score-only fields the canonical
night does not carry: REM and Deep minutes from the stage map, the stage-
breakdown flag from any granular stage, and the midpoint from the asleep span.
Select source and deviceType in the Sleep Score read so the writer-dedup
ladder fires, and thread the user source priority through so the chosen writer
matches every other sleep surface. Round emitted asleep and in-bed minutes to
whole numbers, and round the per-stage map in the sleep-night DTO, matching the
integer contract the native client decodes.
A multi-source user's sleep score and the readiness score that folds it drop
once the double-count is removed, reflecting the true single-writer night.
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.
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.
The predictions panel suppressed its fertile-window and ovulation rows while
fewer than three cycles were observed, but the month calendar did not — so a
brand-new user who logged a single period saw the grid paint a confident
fertile-window shading, a predicted-ovulation dot, and full phase bands off a
population 28/14 prior at ~0.20 confidence. Calendar-based fertility estimation
is unreliable below several recorded cycles, so the grid was asserting days it
could not know.
Thread the same still-learning gate into the calendar build: while fewer than
three cycles are observed, suppress the fertile window, the ovulation dot, and
the phase band (the predicted-period bar stays, mirroring the panel). Surface a
top-level stillLearning flag on the calendar response so the client renders a
calm "learning your cycle" banner over the grid instead of a bare, falsely
confident calendar. The flag is additive and back-compatible; older clients
ignore it and degrade to the previous render.
Also pin the observed-cycle count to a single source — the engine's
post-exclusion value — so the response never carries two divergent counts.
Coherence release: blood-pressure in-target unified on a labeled, thin-data-gated
90-day window across tile, health-score pillar and coach; the dashboard ring and
insights card built from one shared health-score input builder; the coach and the
doctor-report PDF/FHIR adherence drawn from the same dose ledger as the detail page;
and a shared entry-timestamp plausibility guard on measurement and mood. Several
displayed figures become correct-but-different (BP headline window, adherence).
App-wide service-worker registration (installable + offline, authenticated
pages kept out of the cache, logout wipe), era-correct historical dose-edit
attribution, CSV intake stock consumption, WHOOP late-workout ingest, web AI
consent-receipt minting, the layout partial-PUT section merge, the curated
mood-tag catalog, and a sweep of mobile header fixes.
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.
Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.
Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.
Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.
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.
- The escalated card row keeps its time anchor: the red badge carries
the matched window or the overdue-slot value beside it, and the
inline row's range stays visible on mobile, where it was the only
clock on the card.
- Take-all-due collects what the cards offer: a dose escalated past its
catch-up band joins the set with exactly the slot the card's own
button would record; as-needed medications never join.
- The table's stock warning follows the user's runway threshold instead
of a leftover four-dose constant; threshold off means no amber, empty
stays red.
- The card itself shows the remaining runway in the warning tone once
it crosses the threshold — only then, the card stays calm above.
- The low-stock push deep-links straight into the supply tab and its
APNs payload keeps url, runwayDays and thresholdDays; supply tab and
threshold setting link to each other; the reminder-thresholds and
notification-prefs endpoints enter the OpenAPI contract.
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)