The cycle/illness/records note DTOs, the biomarker context, and the custom
mood/cycle labels each degraded an undecryptable value to a blank/generic
fallback with no signal — a key-rotation gap read as 'left blank' and the
catch was silent. Keep the fail-soft read posture (one bad row never 500s
the list) but emit a wide-event on each swallowed decrypt so a systemic
key gap surfaces. The visible per-field 'unreadable' marker in the UI is
the deferred remainder.
A partial-update upsert soft-decrypted the existing encrypted column,
returned null on failure, then re-wrote it — so editing an unrelated
field permanently nulled an undecryptable note or sensitive envelope.
Preserve the existing ciphertext verbatim when the encrypted field is
absent from the update; only re-encrypt on an explicit field write.
Mirrors the v1.18.1 rotation fix.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
The cycle gate checked only the per-user toggle and gender, never the
operator server-wide availability layer. With the Cycle module turned off
by the operator, /api/cycle/* kept serving, the coach injected the cycle
block, the Apple-Health importer kept writing cycle rows, and the
cycle-reminder cron kept firing CYCLE_PERIOD pushes — while nav and the
doctor report already honoured the operator switch.
Add isCycleAvailableForUser, which delegates to isModuleEnabled(userId,
"cycle"): the cycle ModuleKey resolves the per-user toggle AND the
operator availability map, so there is one source of truth and no
duplicated operator logic. Route requireCycleEnabled, the coach cycle
block, and the import accumulator through it; switch the cron to the
injectable isModuleEnabled gate, matching the mood and measurement
reminder crons, and add a skippedModuleDisabled counter. Correct the
cron comment that claimed isCycleEnabled was itself the module gate.
isCycleEnabled stays the pure per-user (user-layer) resolver the module
foundation AND-s at layer one.
Tests: assert requireCycleEnabled returns a 403 cycle.disabled envelope
and the cron skips the push when the operator turns the module off.
Below three observed cycles the prediction is a population-prior guess, not a
data-grounded estimate. The web calendar already hid the fertile window and
predicted ovulation in that state; make the gate structural in the DTO mapper
so iOS — and any client reading prediction.* directly — cannot paint a window
the rest of the app refuses to show. ovulationConfirmed stays goal-gated only:
a confirmed shift is observed data, not a prior.
Add a per-phase "context" line under cycle.phaseEducation.context.* that
expands the existing descriptive-only education with what someone might
notice — energy, sleep, basal temperature, mood — and a gentle framing of
normal variation. The original whatsHappening lines are kept verbatim; this
sits below them as a second, deeper paragraph in the same warm,
medically-grounded, non-prescriptive register.
Wire the new leaf through phase-copy.ts (PHASE_CONTEXT_KEY) and render it in
PhaseEducationCard, and add the copy across all six locales.
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 stored positive ovulation test (LH surge) was ignored by the
predictor. Wire it in as a Marquette/Fehring anchor: a positive surge
predicts ovulation ~24-36h later, so the estimated ovulation is set one
day after the last positive LH test in the current window, sharpening the
estimate over the calendar back-calculation — most valuable in the
thin-data (<3 cycle) case where the median/luteal back-calc is weak.
The LH surge is a SIGNAL that refines, never a sole assertion: it
re-anchors the still-estimated ovulation but never flips
`ovulationConfirmed` (a single indicator is not the symptothermal
double-check), and a confirmed symptothermal/temperature-trend ovulation
always wins over it. The still-learning gate and the un-confirmed status
are preserved. A stale prior-cycle surge whose next-start lands in the
past is rejected.
Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before
ovulation.
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 mucus peak was taken as the last egg-white/watery day with no
confirmation, so a stray late fertile-mucus entry corrupted the peak.
Implement the Sensiplan Höhepunkt rule: the peak day is the LAST
best-quality (egg-white/watery) day that is followed by at least 3
consecutive drier observed days, each strictly lower quality. The peak is
only confirmable retrospectively, after those 3 days, so a stray late
egg-white entry not yet followed by 3 drier days cannot move an
already-confirmed peak. Quality is scored on the t/f/S/+S ladder;
unobserved days are skipped so a logging gap does not break the count.
Citation: Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP
"Zervixschleim beobachten".
The BBT shift detector only applied the strict 3-over-6 rule (3 readings
above the cover line, the 3rd >=0.2C above), so it under-detected
ovulation on common ovulatory patterns. Add the two published
Ausnahmeregeln:
- 1. Ausnahmeregel (slow/staircase rise): when the 3rd high reading is
above the cover line but not >=0.2C above, a 4th reading that need
only be above the line confirms the shift.
- 2. Ausnahmeregel (one value falls back): when exactly one of the three
readings falls back to/below the cover line, it is discounted and a
4th reading that again clears 0.2C above the line confirms.
The result now carries a `rule` (0|1|2) provenance field and the
`evaluationCompleteDate` (evening of the 3rd or 4th high reading). The
rules follow the Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann
standard and are mutually exclusive by construction.
The hard upper bound clamped cycle lengths at 45 days, force-excluding any
longer cycle as an outlier before the robust statistics ran. Cycles over 35
days are oligomenorrhea but still real, and adolescent and perimenopausal users
routinely run to fifty days or more — exactly the irregular users for whom an
honest estimate matters most. Clamping their genuine cycles biased the estimate
toward the population middle.
Raise the ceiling to 60 days. It stays a coarse gross-error backstop; the
median-plus-MAD fence and the missed-log heuristic still do the real outlier
work, and because the fence is computed before exclusion, raising the ceiling
does not move it — it only stops the hard rule from pre-empting the fence on
long but consistent cycles.
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.
The log sheet's chip grid imported the icon allow-list from custom-symptoms.ts,
which pulls node:crypto + the AES helpers into the client bundle and broke the
Turbopack build for /cycle. Split the client-safe constants and Zod schemas into
custom-symptoms-shared.ts; the server module re-exports them so the API routes
are unchanged, and the client imports from the shared module.
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 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>
Address the second-round audit (server cluster):
- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
stronger "not a contraceptive method" disclaimer — wire the existing
strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
batch instead of re-reading the profile per day; hoist the bulk cycle
attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.
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>
The symptothermal layer ran against the full day-log history: detectTempShift
returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day
across all cycles, so a multi-cycle user with historical BBT confirmed a
months-old ovulation and the predicted next-period start landed in the past.
Scope the temperature/mucus detectors to the current cycle window
[lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift,
and reject any confirmation whose next-start is already in the past.
Resolve luteal length through a single clamp. The predictor clamped to [10,16]
while the calendar adapter and phase mapper used the raw stored value, so the
ovulation dot and the OVULATORY band could land on different days for an
out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar,
buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod
bound to the same [10,16] range.
Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the
algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE.
Bound the day-log reads on the calendar and insights routes to the rendered span
plus the symptothermal lookback instead of the full history; cycle-length stats
already run off the MenstrualCycle rows.
Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR
and per-group day floors. Mood is read from MoodEntry and injected as a synthetic
channel since it is not a measurement row.
Add multi-cycle, luteal-clamp and fertile-window fixtures.
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>
The cycle insights tab rendered an empty placeholder while the phase-contrast
card + its `/api/cycle/insights` feed sat unused. Wire them together: a
`useCycleInsights` read drives the headline finding + the luteal-vs-follicular
crosstab on the insights tab, matching the mood-factor crosstab styling. Drops
the placeholder card + a duplicate disclaimer component in favour of the ones
already in use, and removes the unused recency/phase-result/label exports.
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>
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>
Pure, DB-free cycle prediction and phase-assignment engine under
src/lib/cycle/. The math mirrors the offline-parity spec exactly so the
server and the native client compute byte-identical forecasts from the
same canonical inputs.
- day-math: noon-UTC anchored YYYY-MM-DD arithmetic (dayDiff, addDays,
round-half-up) that stays stable across timezones and DST transitions.
- prediction: robust median+MAD cycle-length estimator over a trailing
12-cycle window with 3-MAD outlier exclusion and missed-log detection;
period-length estimator; one-sigma Low/High date band widened by
logging sparsity; luteal back-calculated ovulation + six-day fertile
window; the CALENDAR -> symptothermal -> temperature-trend method
ladder (sensiplan 3-over-6 + mucus-peak re-implemented from the
published method) with documented blend precedence; a multiplicative
confidence scalar shrinking with length variance, low cycle count, and
sparse logging.
- phase: day-of-cycle -> MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL boundary
mapping with menstrual precedence on overlap.
- 48 unit tests covering the regular, irregular, cold-start, outlier,
symptothermal, temperature-trend, and every phase-boundary case; all
assertions are timezone-independent and pin exact dates + confidence.
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>