The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
The score ring's bloom was clipped by the SVG's own viewport — the
drop-shadow sits on a child of the viewBox-filling arc — so the glow
never extended past the circle; the SVG now overflows visibly on every
host. The insight pill strip gains chevron buttons and wheel scrolling
so an overflowing row is reachable with a mouse. Safari favourites
rendered the transparent icon on a white plate; a flattened
apple-touch-icon and a maskable manifest icon fix both. The
time-of-day mood chart adopts the damped fill the weekday chart already
uses, eighteen delete confirmations move onto the destructive button
variant (one of them had the wrong text colour), five confirmation
dialogs gain pending states, the sources dialog stacks its primary
action on top on mobile, and the token form aligns input and button
heights.
An intake edit accepted any takenAt value, so a date typo could park a
dose a month away from its slot and poison the today feed, the rollup
and the rolling next-due anchor at once. The update schema now rejects
future timestamps (with clock-skew tolerance) and values older than five
years, the route rejects edits before the medication's start date, and
the edit dialog caps the picker at now, shows the scheduled slot and
warns when the new time sits more than 48 hours from it. The dialog's
note field is gone — it was never persisted and silently discarded
input.
Introduce <DoseWindowEditor>, the configurable on-time window control
shared by the schedule tab and the wizard. Per dose time the user keeps
the precise time (the symmetric +/-1h default band) or sets an explicit
range such as 07:00-09:00; the row shows the resulting on-time band plus
the cadence-aware late tail. The component is controlled over the
persisted doseWindows contract ([{timeOfDay,start,end}]) and validates
HH:mm, start<=end, and timeOfDay membership to mirror the server schema.
Pure window math lives in dose-window.ts (band derivation, late tail,
default-vs-explicit detection, serialise) so it is unit-tested without a
render. Adds the medications.detail.zeitplan namespace across six
locales.
Replace the Verlauf tab's flat intake table with the per-slot dose-history
ledger the shared band engine mints. The tab now renders EVERY expected slot
with its status — on-time, late, skipped, missed, upcoming — even when the
dose was never taken, and tags every off-schedule intake as a standalone
ad-hoc row at its real time. Rows group by day, most-recent first.
Genommen / Übersprungen sit on the dose row and recompute instantly: the
cached ledger and the headline adherence are mutated optimistically (the
engines are pure, so the client mirrors their accounting) before the write
fires, then the dependent-key invalidation reconciles; a failed write rolls
the optimistic snapshot back. A header add dialog records a dose — including a
backdated or off-schedule one — and an off-window take surfaces the "diesem
Slot zuordnen?" nudge, pinning it onto a chosen slot via forceSlotInstant
rather than orphaning it. A compact took/skipped bar sits at the top of the
tab; the card body still never tints by status.
Adherence is derived from the same ledger the user is reading (taken over
taken + missed; skip, ad-hoc, upcoming excluded), so the history view and the
percentage cannot contradict. New status + dialog copy in all six locales.
Make /medications/[id] the canonical full-page home for one medication:
a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf ·
Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a
"Vollständig bearbeiten" jump into the wizard for structural edits.
Everyday levers move inline into the tabs.
Übersicht surfaces the adherence bars, Erinnerung owns notifications +
reminder grace, Bestand reads the pen/vial inventory (all medications,
calm empty state), Verlauf renders the editable intake history, and
Injektion (injectable routes only) carries the existing GLP-1 charts +
side-effect log. The ledger, the per-dose window editor and the
injection body-map land in the following waves.
The legacy /medications/[id]/history route now redirects to ?tab=verlauf
for bookmarks and deep links. Tab labels are i18n across six locales.
The Insights overview customise surface now has a home in Settings,
analogous to the Dashboard section. It hosts two blocks:
- "Arrange the overview" embeds the inline section show/hide + order
editor, persisting to insightsLayoutJson.sections.
- "Sort the navigation pills" is a dedicated drag-reorder control for
the tab-strip pills, persisting to insightsLayoutJson.tiles[].order —
a field already separate from the section order in layout v2, so the
two concerns stay independent. Both write through the existing
/api/insights/layout contract the tab strip and overview share.
Wire the new slug into the settings registry, the section router, the
mobile/desktop nav, and the placeholder icon map. Adds the section copy
and the pill-sort + overview-arrange strings across all six locales.
The default insights layout shipped a curated visible subset (v1.15.11),
authored for the overview Vitals/Mobility grid. Once the tab strip began
gating its nav pills on the same layout, that subset dropped ~20 nav pills
for a fresh account with data in Sleep, Steps, Active-Energy, body
composition, Walking/mobility, Audio, Daylight, etc. — pills that show
today — and caused a flash-then-vanish on first paint.
The grid renders a fixed mapped subset regardless of tile visibility, so
defaulting every tile visible does not bloat it; what it restores is the
nav's everything-with-data default. The user hides what they don't want.
Also:
- mood distribution + weekday charts: switch axis tick fill from the raw
--dracula-fg (white-on-white on the light-mode card) to the shared
--muted-foreground axis-text token; dark mode unchanged.
- drop the orphaned insights.editMode.manageTiles key from all six locales
(replaced by manageSubPages) and add a manageSubPagesHint noting that
hiding a detail page also removes its top-nav pill.
- pin the all-visible default with a layout test and a tab-strip
default-nav regression test.
The Anpassen tile manager only listed the ~14 vitals overview-grid
tiles, so the rest of the routed sub-pages had no toggle anywhere. Now
that the tab strip reads the same per-tile visibility, the manager
becomes the one place to show, hide, and reorder every sub-page.
Broaden the Kacheln-verwalten disclosure to the full sub-page universe,
grouped by category (vitals, body, activity, sleep, cardiovascular,
hearing, environment, metabolic, mood, events) the same way the strip
groups them. Each group is its own drag context so a reorder never
crosses a group boundary; rows reuse the strip's labels so a manager row
reads like the nav pill it governs. The single tiles layout this writes
now drives the overview vitals grid, the tab-strip pills, and this
manager. Relabel the disclosure to Detailseiten verwalten / Manage
detail pages across the six locales.
The page-scoped selection bar sat sticky bottom-2, behind the fixed
64px mobile bottom-nav, so the destructive Delete (N) button was
unreachable on phones and ignored the iOS home-indicator inset. Lift
it to clear the nav band + safe area on mobile and drop back to
bottom-4 at sm+ where the nav is hidden.
The per-row select checkbox wrapped a Radix checkbox (a 16px button)
in a 44px label; a label does not forward taps to a button, so the
effective target failed WCAG 2.5.5. Make the 44px area a single
button that owns the toggle, with the checkbox a pointer-events-none
controlled visual, avoiding any double-toggle.
Bring the mood pagination buttons to the 44px target used by the
measurements list. Add (sort: Desc) to the two source-filter indexes
so the schema matches the DESC migration SQL; schema-fidelity only,
no new migration.
Extract the duplicated list chrome — SortableHead and the single-row
DeleteButton — into a shared src/components/data-list/ module and add a
page-scoped multi-select with a selection action bar, used by both the
measurements and mood management lists.
Filter bar: each list keeps its primary Select (type / mood level) and
gains a source Select (measurements sends sourceEq, mood sends source)
plus an optional date range wired to the existing from/to params. Every
filter resets pagination to page 1 and is baked into the query key via
the centralised factory (queryKeys.measurementsList / moodEntriesList)
so the cache stays correct per filter combination.
Multi-select is scoped to the current page: a leading checkbox column on
desktop, a checkbox on the mobile cards, and a select-all-on-page header
checkbox. When at least one row is selected an action bar shows the count,
a clear affordance, and a destructive Delete (N) behind an AlertDialog
confirm that POSTs to the bulk-delete route, then invalidates the same
dependent-key bundle the single delete busts and clears the selection.
Selection clears on any page / filter / sort change; the selection math
lives in a pure, unit-tested helper. The locked-type insights subpage
keeps its focused single-metric view (source filter + date range
suppressed).
New strings land in a dataList namespace plus per-list source-filter and
bulk-delete keys, translated across all six locales.
The score-anatomy detail page now renders the per-score AI assessment that
already rides GET /api/insights/derived (shipped to iOS in v1.13.2) — the web
type dropped the field, so the card was silently discarded. The assessment
mounts below the composition card via the same InsightStatusCard the metric
sub-pages use, so it reads identically for readiness, sleep, recovery, strain
and stress.
Each score detail page also gains an educational intro caption beneath the
heading, mirroring the metric sub-pages: new explainer bodies for the five
scores across all six locales (sleepScore kept distinct from the sleep
metric's own body), wired through SubPageShell's explainerMetric prop.
The explainer-key guard resolves the score page's closed lookup record
statically so the single dynamic prop stays verifiable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a dedicated version tile to the admin overview showing the running
version, short build SHA, and build timestamp, alongside a best-effort
'update available' hint computed against the latest GitHub release tag. The
hint reuses the existing /api/version/check-updates proxy (server-side
safeFetch past the CSP) so no new route or OpenAPI schema is introduced; the
check is day-stale and never blocks the render. Cookie-admin only by virtue of
the admin layout.
Shorten the period-narrative section title to 'Rückblick' (and the idiomatic
short equivalent per locale) — the same i18n key the customizable-overview
edit-mode labels read, so the rename flows through automatically.
Drop the redundant 300px min-height floor on the Trends cards. The chart slot
is a fixed 180px and the caption is clamped to three lines, so the floor
padded dead space below every caption that read as an oversized Trends to
Rückblick seam. The grid's auto-rows-fr plus the fixed chart slot already pin
the cards to a single baseline, so the seam now matches the uniform rhythm.
Shorten the notification-area label and inbox title from the longer
"Notification Center" form to plain "Notifications" across all six
locales (nav entry, page title, breadcrumb). Refresh the stale inbox
references in the settings-shell and sections tests.
The GLP-1 card overrode the shared next/last slot with appointment-phrased
labels (Next / Last, "Nächster Termin" / "Letzter Termin") while the generic
card used Next intake / Last intake. Drop the override so both cards name the
concept identically; the GLP-1 relative-day value phrasing (in N days / today)
is unchanged. Remove the now-dead label override props from the shared slot
and the orphaned glp1NextLabel / glp1LastLabel strings from all locales.
Relabel the Export section to Export & Import (heading + description,
all six locales; slug unchanged so existing links keep working) and
mount the import area in it. Add a cycle export tile that reuses the
health-record FHIR export with the reproductive section opted in, gated
on cycle tracking being enabled so a non-cycle account never sees it.
Compress the health-record export hero — format and date range share a
row, the practice-name field tightens, and the FHIR note rides the
footer with the generate action — so the card opens denser without
losing any controls. Add the import + cycle i18n keys across all
locales.
Make "Period in review" the period-narrative card heading (a proper
title, not a faint uppercase label) and drop the possessive across all
locales.
Render the today-signal card only when there is an actual signal: an
all-clear day (every vital inside its personal range) or an account still
building its baselines now produces no card, rather than an empty header.
Remove the moodLog card, its render, and the moodLog-only UI plumbing
from the integrations settings — the integration is no longer offered.
The backing columns, routes, and status aggregator entry stay in place
for a later dedicated removal. Also shorten the integration save button
from "Save credentials" to "Save" across all locales, and add the
nav account-settings label.
Surface the cycle phase ring in the main Insights health-scores strip as a
gated sibling tile. It reuses the cycle wheel SVG, sized to a score-ring
tile, and sources day-of-cycle + phase from the same calendar read +
deriveWheelState the cycle page wheel uses, so the two surfaces never
disagree. Mounted only for a cycle-tracking account (the same /api/auth/me
gate the sidebar nav and the cycle summary card use), so its calendar read
never fires otherwise; it renders nothing while resolving, on error, and
when there is no active cycle — no gap, no placeholder.
Cap the adaptive cell size in the mood and medication compliance heatmaps.
A short window (a single medication, a 30-day mood calendar) left only a
few columns, so the container-width cell sizing ballooned and the square
grid blew the tile to roughly three times its neighbours' height. The cell
ceiling keeps the grid dense and left-aligned within the tile-height rhythm
at mobile and desktop.
Add a compact cycle teaser to the main Insights overview for accounts with
cycle tracking enabled. The card shows the current phase, cycle day, and the
single headline phase finding, and deep-links into the full cycle insights tab
so the cycle tab stays the single source of truth.
The card mounts only when cycleTrackingEnabled resolves true from
/api/auth/me — the same gate the sidebar cycle entry uses — and renders nothing
for every other account. Phase and cycle day derive from the same calendar read
plus deriveWheelState the cycle wheel uses, and the headline reuses the shared
CyclePhaseHeadline, including its honest empty line when nothing has cleared the
significance check.
The cycle view now opens the insights tab when the URL carries ?tab=insights,
validated against the known tab values; CycleView is wrapped in a Suspense
boundary for the search-params read.
The phase-crosstab server emits display "mood" and "glucose", but the
client unit/label maps only covered the vital channels, so a surviving
mood or glucose phase correlation passed an undefined key to t() and
crashed the insights tab. Add the missing maps and harden both unit
lookups to fall back to an empty unit on any unmapped display.
Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint
their own symptoms beyond the 15 seeded ones, picked from a label + icon
popover via a dashed ghost-chip in the log-day sheet's symptom grid.
- POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a
custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM),
per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by
default, hard-purge on ?purge=true.
- The day-log write already resolves a symptom key against the catalogue plus
the caller's own rows, so a custom: key links by construction.
- The log-day sheet fetches the user's customs and merges them under a new
custom category in the chip grid; the ghost-chip + popover reuse the chip
styling and the Lucide icon set.
- Seed the global custom symptom category (migration 0134).
- The purge route (DELETE /api/cycle/all) now drops the user's custom symptom
rows too, so no intent-revealing label survives a purge.
- The label is never logged: only the icon + counts reach annotate/auditLog.
- i18n: cycle.symptom.custom.* across all six locales.
Add a CYCLE_FERTILE_SOON reminder that fires two days before the
predicted fertile-window start. Twice-gated and default-OFF: it only
surfaces when CycleProfile.goal is TRYING_TO_CONCEIVE and fertileWindowStart
is populated, so fertile language never reaches the avoid / general / peri
goals. Reuses the existing cycle-reminder cron, discreet mode, clientManaged
suppression, idempotency ledger, and push-attempts plumbing verbatim; the
body carries the non-medical, not-a-contraceptive framing.
Add the cycle-native counterpart to the phase×vitals crosstab: for each
logged symptom, where in the cycle it clusters. A pure aggregator tallies
each symptom's occurrences across the phase-labelled days (the same phase
map the calendar uses), surfaces only symptoms logged on at least three
phased days, and reports per-phase counts + the dominant phase + its
share. The insights tab renders it as a proportional phase-hued bar with
the dominant phase named in words (never colour-only), observational and
never causal.
Also correct the phase-crosstab OpenAPI row enum, which had drifted
behind the runtime (mood + glucose channels were missing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the second-round audit UI cluster:
- Cycle reminders: add the missing notification-event labels so the
period-soon / period-confirm toggles read correctly on the
notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
length, confirmed-ovulation chip) — the cycles were fetched but only
the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
the nullable PATCH); period start/end work on any selected date for
retroactive correction; "end period" shows only while one is open.
Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
in before the gated page is reachable; wire the cycle data-purge button
to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
the grid; tighten the luteal prior bound to the engine clamp; mute the
BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the cycle web vertical's signature surfaces:
- Rebuild the cycle wheel to the wellness-ring premium signature — a
clockwise clock-wipe that draws the phase arcs on from the 12-o'clock
day-0 origin, a phase-tinted bloom on the active arc, the shared
specular sheen, a day-number count-up, and a soft current-day marker
halo. All on the wellness-tile surface, gated once per session and
zeroed under prefers-reduced-motion.
- Add the basal-body-temperature chart to the predictions tab: the
current-cycle BBT series with phase-tinted points, fertility signs in
the tooltip, and an estimated-ovulation reference line (suppressed in
raw-chart mode). Surface basalBodyTempC + ovulation-test + mucus on the
calendar day DTO so the chart reads from the existing calendar load.
- Cover the read states the prediction/disclaimer surfaces were missing:
pass the disclaimer to the predictions panel, render it on the calendar
tab, add per-tab error + retry, and gate the settings spinner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `GET /api/cycle/day-logs?date=YYYY-MM-DD` returning the full day-log DTO
(or null) for one tz-anchored day so the web log-day sheet pre-fills instead
of opening blank — a blank re-log no longer nulls untouched or HealthKit-sourced
fields. Delete resolves the row id from the same read.
Persist an optional 1-4 Likert severity per symptom link (migration
0133_v1150_cycle_symptom_severity), carried through the shared symptom-link
writer and surfaced in the day-log DTO.
Sheet now captures the previously missing fields — spotting between periods,
pregnancy/progesterone home-test results, contraceptive kind — plus per-symptom
severity chips, a one-tap end-period boundary, and a Delete action. The wheel
ring marker sits at the 12-o'clock origin again (drop the double rotation), the
cycle view goes two-column on desktop, and the empty ring carries a first-period
call to action. New copy is localized across all six locales; the prediction and
fertility surfaces keep their disclaimers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- When the sensitive-category encryption preference is on (the default),
encrypt the intent-revealing day-log fields (sexual activity, protection,
pregnancy/progesterone tests, contraception) into an encrypted envelope and
leave the queryable columns null, decrypting on read. The toggle now does
what it says rather than storing those fields in plaintext.
- Make the day-log write a partial merge so a re-log only overwrites the
fields it carries, never nulling untouched ones (no more data loss when the
same day is logged again).
- Make the upsert collision-safe: a manual entry and an imported entry on the
same date adopt one canonical row instead of throwing, and the single POST
returns a clean conflict rather than a 500; one bad date no longer aborts a
whole import.
- Compare the decrypted note, not the ciphertext, so an unchanged re-log is a
true no-op and stops needless re-sync.
- Hoist cycle attribution out of the per-entry loop; map bulk failures to
stable reason codes; audit the import goal change; mask the event name in
discreet reminder pushes; add a one-tap hard-delete purge for all cycle data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "OTHER" to the accepted gender values (the column is a free String, so no
migration) and surface it in the account selector. This lets a non-binary or
trans user record their gender and opt into the cycle-tracking surface through
the per-user enable flag, rather than being gated out by a binary field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the reproductive HealthKit category types out of the
Apple-Health-import privacy-hold list and route them into cycle day-logs
instead of measurements, and surface cycle data on the three export
surfaces.
Import promotion:
- A new HealthKit -> cycle mapping module is the single source of truth for
how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality,
OvulationTestResult, SexualActivity, PregnancyTestResult,
ProgesteroneTestResult, Contraceptive and the symptom category types fold
into CycleDayLog fields / seeded symptom keys. The XML importer
accumulates same-day reproductive samples and writes one canonical
day-log per day with source APPLE_HEALTH and a synthetic per-day
externalId for idempotent re-import. The fold is gated on cycle tracking
being enabled, so a non-cycle export never provisions cycle rows. A
contraceptive method nudges the profile goal toward avoiding pregnancy
only when the goal is still on its default. Basal body temperature stays
a measurement and mirrors onto the day-log. Pregnancy/Lactation status
and the pregnancy-bleeding types stay deferred; the cycle awareness types
remain server-derived.
Export:
- FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period
length (64698-4) and the current phase as opt-in reproductive-health
observations grouped into their own document section.
- The doctor PDF gains an opt-in cycle section (recent cycles, average
length and variability, current phase), privacy-default off like mood.
- The full-backup export, the weekly backup worker and the off-host DR
snapshot include the cycle tables, and the admin restore re-creates them;
the day-log note round-trips as ciphertext and is never decrypted onto a
report surface.
Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit
+ integration coverage for the mapping, the import routing, the FHIR
observations and the backup round-trip. OpenAPI regenerated. Six-locale
strings added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the web UI for the cycle-tracking category against the existing
`/api/cycle/*` routes: a month calendar + circular phase wheel, a fast
log-day sheet, a predictions panel, phase-insight cards, and a single-page
settings form, all under a gated `/cycle` tab.
- Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the
wellness-ring aesthetic with calm per-phase hues) showing the current
phase + day-of-cycle, alongside a month grid where logged period days show
a filled pip, the predicted next period renders as a ranged band (never a
single date), the fertile window shows only when the goal allows it
(the API suppresses it server-side otherwise), and ovulation + per-day
symptoms carry markers. Reads `GET /api/cycle/calendar`.
- Log-day sheet: one-tap period start, flow / symptom / ovulation-test /
mucus chips, basal-temperature, intercourse + protection, and a free-text
note. Optimistic write to the day-log + period routes; invalidates the
cycle query keys.
- Predictions panel: next-period range with a confidence pill, goal-gated
fertile window, a still-learning state for fewer than three cycles or
raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer
(reusing the drug-level-estimate disclaimer pattern).
- Insights cards render whatever phase-correlation cards the API surfaces,
with a graceful empty-state until the feed lands.
- Settings: a single dynamic form where the inclusive goal selector drives
the copy (conception framing only for trying-to-conceive), plus typical-
length priors, raw-chart mode, sensitive-category encryption, discreet
notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`.
- Nav: a Cycle entry in the sidebar and the mobile More hub, gated on
`cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise.
Add the cycle query keys + dependent-key bundle to the factory, full copy
across all six locales, and dedicated per-theme phase hues. Plain
second-person voice, WCAG 2.1 AA, no new dependencies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two cycle reminders through the existing notification cascade and the
honest self-hosting privacy framing for reproductive-health data.
Notifications:
- New event types CYCLE_PERIOD_SOON (fires ~2 days before the predicted
next-period start, from the cached prediction) and CYCLE_PERIOD_CONFIRM
(a gentle "did your period start?" on/after the predicted start while no
period is logged, within a short grace window). Both default OFF and are
gated again on cycleTrackingEnabled — opt in twice for a sensitive surface.
- A 15-minute pg-boss cron mirrors the mood-reminder worker: it scans users
with cycle tracking enabled + predictionEnabled, computes who is in-window
from the cached forecast, and dispatches one push per event per local day.
Idempotency anchors on the push_attempts ledger (no new table). The queue
is declared, registered in allQueues, scheduled, and wired to a boss.work
handler, with a source-grep guard so it cannot ship unregistered.
- Honours notificationPrefs.cycle.clientManaged (skip the server push when
the device owns local reminders, with a suppressed annotation, mirroring
the medication path) and CycleProfile.discreetNotifications (generic
"HealthLog reminder" body so no cycle event is named on the lock screen).
- Every attempt is recorded in push_attempts via the unchanged dispatcher.
Privacy / disclaimer:
- docs/self-hosting/reproductive-health-data.md — the "no cloud vendor to
subpoena" framing with the honest residual-threat caveat (your own host,
provider, backups, and devices remain part of the surface), citing the
2021 FTC settlement and 2025 verdict as public record only.
- docs/security/cycle-data-threat-model.md — a compact "protects against /
does not protect against" note, plus README pointers.
- CyclePredictionDisclaimer component (reusing the GLP-1 estimate-disclaimer
pattern) for every fertility/prediction surface: descriptive, not a
contraceptive method, not medical advice, never a safe-to-have-unprotected
-sex claim. 6-locale copy under cycle.disclaimer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the menstrual-cycle phase as a channel in the existing FDR-guarded
correlation machinery, the same way rated mood factors were folded in —
no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same
day floors and q = 0.10 gate.
Two complementary mechanisms, both confined to a gender-gated surface:
- a categorical luteal-vs-follicular contrast per outcome metric (resting
heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature),
surfacing one headline finding (resting-heart-rate-by-phase, falling
back to HRV); and
- the continuous phase ordinal folded into the lagged-Pearson matrix.
Phase never reaches the general correlations route — the temperature
channels held out of the general matrix are un-blocked only here, behind
the cycle gate, so a reproductive inference can never leak onto a
non-gated surface. Every row shows n, effect size, p and q; nothing is
fabricated on thin data and nothing is framed as causal.
Surface it through a new gated GET /api/cycle/insights and a drop-in
card that mirrors the mood-factor crosstab.
Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle,
the next predicted event (period range + confidence + method, fertile
window goal-gated), and the headline phase finding — present only for a
cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground
rule keeps the Coach descriptive: never contraception-grade, never a
"safe day" claim.
OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added;
copy localised across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the `/api/cycle/*` capture, calendar, history, and settings routes
plus the offline-sync delta extension for the v1.15 cycle-tracking
feature, all against the locked iOS contract.
- Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile
(migration 0132). A null toggle derives from gender (FEMALE → on).
`isCycleEnabled()` is the single gate resolver; every `/api/cycle/*`
route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved
flag surfaces additively on GET /api/auth/me.
- Routes (apiHandler + Zod + envelope + gate + annotate + auditLog):
POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent,
capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/
skipped), POST period boundary, GET calendar (runs the deterministic
engine, debounced stale-while-revalidate cache, goal-gated fertile
window), GET cycles (history + median/MAD stats), GET profile,
DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge).
- Day-log note encrypts at rest (notesEncrypted); intent fields stay
queryable plaintext. Upsert keys on (userId, source, externalId) when
externalId present, else (userId, date).
- Offline sync: extend GET /api/sync/changes + the cursor with the
`cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays
tombstones key on externalId else id; cycles on id.
- OpenAPI: register every route + DTO; regenerate the committed spec.
- i18n: cycle prediction disclaimer across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce the persistence layer for cycle tracking: eight enums
(CycleTrackingGoal, FlowLevel, OvulationTest, CervicalMucus,
HomeTestResult, ContraceptiveKind, CyclePhase, PredictionMethod) and
seven models.
CycleProfile is a 1:1 per-user settings row (lazily upserted on first
access via getOrCreateCycleProfile). MenstrualCycle and CycleDayLog are
the capture rows, carrying the MoodEntry soft-delete + syncVersion shape
so they ride the sync delta feed; CycleDayLog repeats the MoodEntry
dual-unique — the canonical (userId, date) key plus the NULL-distinct
(userId, source, externalId) key for HealthKit re-ingest. Free-text
notes encrypt at rest; queryable physiology (flow, BBT, OPK, mucus,
dates) stays plaintext for the rollup and correlation tier. The symptom
taxonomy (CycleSymptomCategory / CycleSymptom / CycleSymptomLink) clones
the mood-tag trio and seeds fifteen symptoms across three categories
with cycle.symptom.* i18n keys across all six locales. CyclePrediction
caches one forecast per user, regenerated debounced.
Migrations 0128-0130 are additive; the testcontainer integration suite
applies the full history from scratch and passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Medication cycle status: re-hydrate `nextDueAt` (it arrives as an ISO string
over the wire) before the Berlin day-bucketing, so "next dose in N days" no
longer drifts by a day near midnight or off Berlin. Covered by a new
string-input test.
- Wellness rings: drive the conic sheen off a fixed white so it always lightens
(a near-black light-mode foreground under overlay was darkening the tile into
a grey band); give the ring a transform transition so it scales smoothly with
the tile lift instead of snapping; cap the green-band glow at a few breaths
instead of an infinite drop-shadow repaint; deepen the light-mode stress and
recovery end-stops for non-text contrast; track the sm/md column count to the
active-score count so two or four scores never leave an empty cell.
- Mood factor crosstab: bound the snapshot's cross-metric read to the 365-day
window and exclude soft-deleted rows, so the factor deltas the model cites
match the visible board over the same canonical row set (no recency-biased
truncation, no soft-delete skew); thread the user's source priority so the
per-day canonical pick agrees with the screen; render the delta neutrally
rather than green/red, since a higher vital is good for steps but bad for
resting heart rate and the board is observational.
- Log-intake: surface an error when a pinned slot can't resolve to an instant
instead of silently logging the dose through the unscheduled path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rated mood factors (work / sleep-quality / stress, scored per entry) now
participate in the cross-metric correlation surface. Until now their score
series was captured but read by zero analytics.
Two paths, both on the existing FDR-controlled engine:
- A factor x vital crosstab on the mood insights page and the mood
snapshot: for each factor the user scores, split its days low vs high by
the factor's own median, then run the same Welch test + day floors +
Benjamini-Hochberg correction the tag crosstab uses. Surfaces lines like
"sleep runs ~38 min below baseline on the days you rated work low"
(n-gated, q <= 0.10, never fabricated on thin data).
- Factor channels (FACTOR:<key>) fold into the period-narrative discovery
matrix as both lag source and lag target, so the drivers the coach and
narrative reference can cite a factor relationship.
An inverse factor (higher rating = worse day) gets the documented sign-flip
once, at the series boundary, so "up" always reads as a better day and the
low/high split and phrasing stay honest. Strings land in all six locales;
the surface carries the standing "association, not cause" caption.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The top medications-page Add button now opens a choice menu with two
paths: log an intake against an existing medication — including a
backdated one — or create a new medication (the existing wizard,
unchanged).
The log-intake dialog lists the active medications, optionally pins a
schedule slot, and picks a date and time that defaults to now but accepts
any past instant. It submits to the existing per-medication intake route:
takenAt carries the (possibly backdated) instant, and a chosen slot
supplies scheduledFor so the write routes through the server's canonical
slot upsert — the same snap path a live Taken tap uses — keeping the
one-row-per-dose-slot invariant intact. No new endpoint.
Submit orchestration lives in a pure, dependency-injected runLogIntake
helper alongside the shared take/skip logic, invalidating the medication
dependent query-key bundle so the dashboard and compliance views refresh.
New strings land in all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the per-med `complianceDisplay.currentCycle` descriptor into a calm,
rate-decoupled status line on both the generic and GLP-1 medication cards.
A sparse weekly or rolling med (a GLP-1 injection) that is simply between
doses has no closed cycle in flight, so the compliance percentage alone reads
as a scary 0% that says nothing about whether the user is on schedule. The new
`MedicationCycleStatus` card-part reports the open cycle instead: "next dose in
N days" / "due today" / "overdue", or a neutral "no closed dose cycles yet"
when there is not enough history to score. It complements the percentage rows;
it does not replace them.
Driven by `currentCycle.state` (on_track / due / missed / none). Colour follows
the existing success / warning / destructive ramp and pairs each tier with a
Lucide glyph so colour-blind users can disambiguate (WCAG 1.4.1). The relative
day phrasing buckets on the user's calendar day, matching the next/last slot.
Strings land in all six locale bundles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Let a user shape their own mood-tag set on top of the global catalogue:
create custom tags (label encrypted at rest, icon from a fixed allow-list),
and hide built-in tags per user. Custom tags live in `mood_tags` with a
non-null `user_id` and a `custom:<uuid>` key; `GET /api/mood/tags` returns
the effective per-user set (catalogue − hidden + own customs) with new
`custom` + `label` fields, and `?include=hidden` surfaces hidden catalogue
tags for the management screen. The write path resolves a `custom:` key only
against the caller's own rows — a foreign custom key is dropped, never linked.
Delete soft-deactivates by default (history intact); `?purge=true` removes
the row and its links. BINARY-only for v1.
New: POST/PATCH/DELETE /api/mood/tags/custom[/:key], PUT /api/mood/tags/:key/hidden,
migration 0127 (user_id + label_encrypted columns, mood_tag_hidden table,
`custom` category). Integration setup snapshots + restores the seeded catalogue
because the new mood_tags→users FK makes TRUNCATE users CASCADE wipe it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The next-intake and last-intake lines on the medication cards ran the
label and the time together on one run of text, reading as crammed. Lay
each line out as a two-column row — bold label pinned left, value flush
right — so the two rows read as distinct, aligned columns. The label
truncates first on a narrow viewport; the time never truncates.
The GLP-1 card routes through the same shared slot and now overrides only
the left-column label with its appointment-phrased wording, so the value
column carries the time and the optional rotation nudge. Its value strings
drop the now-redundant leading label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tighten the operator-finalised mood catalog to three RATED factors and a
trimmed default BINARY tag set.
- Seed `factor_sadness` (RATED, 1..5, inverse — higher = worse) under the
`factors` category, with its label in all six locales and a CloudRain
glyph. The RATED set is now work / sleep quality / sadness.
- Retire `factor_conflict`, `factor_family`, `factor_social`,
`factor_stress`: flip each to BINARY and deactivate. The catalog rows
stay so historical entries still resolve their labels (the insights read
joins by id without an active filter); they just leave the RATED set and
the default picker.
- Trim the default BINARY tag set roughly in half: deactivate the
sleep-tag category (now covered by the sleep-quality slider), the mood
level restatements, the nutrition long-tail, and low-signal day-type
tags. Kept rows are the high-signal, correlatable tags. Nothing is
deleted; inactive rows still resolve labels for existing entries.
Reference-data only — no schema change, idempotent upsert + closed-key
updates, no per-user data touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the draggable chart brush. The Min/Max/Median/Mittelwert strip now
always reflects the data visible under the chart's active range tab (7/30/90/
all): the chart reports per-type stats for its range-sliced series and the
sub-page threads them into the strip. No drag, no "selected range" pill — the
range tab is the single selector for both chart and strip. Blood pressure
recomputes both systolic and diastolic.
Give each metric chart card the canonical TileHeader (icon + title, same size
and spacing as the assessment/target/stat tiles) via an optional titleIcon;
the dashboard and mini mounts keep the compact heading.
Drop the now-unused charts.selectRange and insights.subPage.stats.selectedRange
keys from all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add three MeasurementType values for WHOOP fields a coverage audit found
uncaptured: AVERAGE_HEART_RATE and MAX_HEART_RATE (the cycle score's
whole-day aggregates, kept distinct from spot PULSE / RESTING_HEART_RATE /
WALKING_HEART_RATE_AVERAGE) and SLEEP_DISTURBANCE_COUNT (the per-night
disturbance tally). Units: bpm / bpm / count.
Wiring mirrors the existing per-type surfaces, traced from VO2_MAX and the
v1.11.0 WHOOP-native score classes:
- canonical enum + unit + plausibility range in
src/lib/validations/measurement.ts (every ingest / analytics / rollup
path derives from measurementTypeEnum.options, so the mean-based rollup
tier picks the new types up automatically).
- Prisma enum + migration 0125 (ALTER TYPE ADD VALUE IF NOT EXISTS,
non-transactional, idempotent — same pattern as 0111).
- category overlay (cardiovascular for the HR pair, sleep for the count).
- list-view label key / Lucide icon / chart-colour.
- i18n label key in all six locales.
- chart-token allowlist + orphan-enum stripper.
- PersonalRecord direction (HR pair null — no clean goal axis, like
WALKING_HEART_RATE_AVERAGE; disturbance count MIN, like
BREATHING_DISTURBANCES).
- enum-coverage drift guard (count 60 -> 63, doctor-PDF exclusions for the
device-derived signals) + apple-health HK-counterpart exclusion (these
are WHOOP-only; HealthKit ships no first-class daily-aggregate HR
quantity nor a disturbance tally).
The types render on the measurement list + Insights chart surfaces; like the
v1.11.0 WHOOP-native scores they get no dedicated Insights sub-page (the
sub-page map stays curated) and no LOINC / FHIR mapping (not clinical-grade).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>