Skip to content

Add menstrual cycle tracking (BBT) from passive overnight temperature - #137

Draft
henribruvier wants to merge 10 commits into
saksham2001:mainfrom
henribruvier:feature/cycle-tracking
Draft

Add menstrual cycle tracking (BBT) from passive overnight temperature#137
henribruvier wants to merge 10 commits into
saksham2001:mainfrom
henribruvier:feature/cycle-tracking

Conversation

@henribruvier

Copy link
Copy Markdown

Summary

Adds passive menstrual cycle tracking to PulseLoop, built on the overnight skin
temperature the Colmi rings already report — no morning thermometer, no symptom
questionnaires.

  • Nightly BBT (CycleBBTService) — averages temperature samples taken strictly
    inside sleep, so an awake reading or a replayed log can't skew the night.
  • Sensiplan / CLER "3-over-6" (CycleAnalyzer) — computes the coverline,
    confirms the temperature shift, dates ovulation from the first raw high, and
    derives the fertile window and the next-period prediction.
  • UI — a card in the Vitals tab that opens a detail page with a month calendar
    (period, fertile band, ovulation, confirmation day, legend) and a BBT chart with
    the coverline. Period start and "disturbed night" are the only two manual inputs,
    both loggable retrospectively from the calendar.
  • Settings — the whole feature is behind a toggle, off by default.
  • Coach — cycle phase is added to the coach context packet, so the coach can
    reason about it when the user asks it something.

UI placement and the settings toggle follow @saksham2001's guidance in #18
(card in Vitals rather than a dedicated tab).

Related issues

Closes #18

Type of change

  • ✨ New feature (non-breaking change that adds functionality)
  • 🎨 UI / DesignSystem change
  • 🤖 Coach / LLM change (tools, prompts, orchestration)

How was this tested?

  • Added / updated unit tests (PulseLoopTests) — 49 new tests across
    CycleAnalyzerTests, CycleBBTServiceTests, CycleCalendarShadingTests,
    plus additions to DataArchiveTests.
  • Ran the test suite locally — 1167 tests, 0 failures.
  • Tested on a physical device with a real ring — model: Colmi R09
    in progress. Multi-day validation against a real cycle is exactly what
    this draft is waiting on, per your note on [Feature]: Menstrual cycle tracking (BBT) via Colmi overnight temperature #18. I'll flip this to ready once
    I have several nights of real data behind it.
  • Tested with demo data (-seedDemo YES, no hardware) — the demo seed now
    generates a biphasic cycle (one completed 28-day cycle plus the current one),
    so the calendar, predictions and BBT shift are all reviewable without a ring.

SwiftLint is clean: 0 errors, and no new warnings against the main baseline.

Privacy & data

  • This change does not send health data off-device without explicit user action.
  • No secrets, API keys, or personal data are committed.

Flagging two data-surface changes explicitly, per CONTRIBUTING:

  1. Coach contextCoachContextPacket gains cycle fields. They travel only
    inside a coach question the user explicitly chooses to send, on the existing
    path; nothing new leaves the device on its own, and nothing leaves at all while
    the feature toggle is off.
  2. Local archive — cycle days are included in the full-data JSON export
    (DataArchive), which stays on device unless the user exports it themselves.
    Round-trip coverage is in DataArchiveTests.

Cycle data is stored in SwiftData alongside the rest, with no new storage location.

Screenshots / recordings

To follow — I'll attach calendar and BBT chart captures before marking this ready.

Checklist

  • My code follows the project's style (SwiftLint passes).
  • I ran the tests and they pass.
  • I updated docs / README where relevant — not yet; happy to add a README line
    if you want the feature listed there.
  • I read the Contributing guide.

Opening as a draft: the code is complete and green, but it hasn't yet had the
several days of real-ring wear you asked for on #18. Putting it up now so you can
see the shape of it early and steer the approach before I polish.

🤖 Generated with Claude Code

henribruvier and others added 10 commits September 2, 2026 18:22
Implements the feature discussed in saksham2001#18: a Vitals-tab cycle card + detail
screen that derives basal body temperature from the Colmi ring's passive
overnight skin-temperature samples, applies Sensiplan 3-over-6 mechanics
(both classical exceptions) on baseline-relative smoothed values, and
predicts the next period from personal luteal/cycle statistics.

- CycleDay model (per-day period/disturbed/notes facts; day 1 derived)
- CycleBBTService: nightly median inside the sleep session, awake blocks
  excluded, stable-window fallback when no session was detected
- CycleAnalyzer: pure, deterministic detection + predictions + flags
  (anovulatory, 18-day high plateau, long cycle) + fever auto-suggestion
- Settings: opt-in behind an explicit not-a-medical-device disclaimer,
  tracking goal (understand/conceive/avoid — avoid widens the displayed
  fertile window and repeats the warning), hormonal-contraception mode
- UI: phase ring, BBT chart with coverline and fertile band (°C/°F),
  month calendar with quick log sheet, past-cycle history, one-tap
  "My period started" button
- Notifications: "period due today?" prompt with a lock-screen log action
- Privacy: cycle data never enters coach context or diagnostics exports
  unless the dedicated coach-sharing toggle is enabled (off by default);
  gated on WearableCapability.temperature (Colmi only)
- Demo seed: biphasic overnight temps + period logs; 32 new unit tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lendar digits

- Phase ring: inset the stroked track by half the line width so the band stays
  inside the frame, and put the day marker exactly on the band's centerline —
  it previously sat half a line-width inside. Marker is bigger with a card-
  colored outline so it reads cleanly over any segment.
- Calendar day cells: fixed-size circle + center-aligned ZStack so the digit is
  dead-center; the ovulation star and disturbed dot move to overlays so they
  can't skew the layout. Digits are monospaced for steadiness across days.
- Add the `-openCycle` launch arg (same pattern as -openWorkout) so screenshot
  tooling can deep-link straight to the cycle detail screen.

Verified visually in the iPhone 16 simulator with demo data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two compact rows explaining every marker: logged vs predicted period days,
fertile-window tint, the estimated-ovulation star, and the excluded-night dot.
Fertility items hide in hormonal-contraception mode, where those markers never
appear. Verified in the iPhone 16 simulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The export/import archive predates cycle tracking, so restoring a backup
or migrating devices silently dropped every logged period day, excluded
night and note. Mirror `CycleDay` the way the other 24 models are:

- `ArchiveCycleDay` DTO carrying every stored field; import restores
  `dateString`/`date`/`updatedAt` verbatim instead of letting the init
  re-derive them from the current timezone (dateString is the unique key).
- `cycleDays` is optional on the envelope so archives written before
  cycle tracking still decode; the format version stays at 1.
- Export, counts, hasAnyData, wipeAllData, validate (unique dateString)
  and insertAll all cover the new table, so re-importing is idempotent.
- The Backup footer now names cycle logs among the exported data.
- Tests: round trip by date key (a period day, a disturbed night with a
  note and a cross-timezone date, a note-only day), re-import
  idempotency, a legacy archive without the key, and CycleDay in the
  all-models count/wipe checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The basal temperature was the median of the temperature rows inside the
first sleep session found for the day, through a fetch capped at 500
rows newest-first. On a store where every sync re-appended the ring's
temperature log, that median was weighted by sync count rather than
time, the cap silently dropped the early night once a night exceeded
500 rows, and the value of a past night moved from one sync to the
next (one real store: 36.9 -> 36.8 overnight, the difference between
"above the coverline" and "at the baseline").

- One value per slot: temperature rows are collapsed by whole-second
  timestamp (latest row wins) before any statistic, in the session path
  and the stable-window fallback alike.
- The 500-row cap is lifted for the night window.
- The night is the day's longest session, not the first one fetched, so
  a nap or a fragment of a split night cannot stand in for the night.

Tests: duplicated rows do not skew the median, more than 500 rows keep
the whole night, a revised slot takes its latest row, the longest
session is the night.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Past day 60 the "long cycle — waiting for data" flag outranked the
ovulation confirmation and the period countdown in the headline, and its
banner claimed estimates were paused although they were computed. In a
postpartum return (or PCOS, perimenopause) the first ovulation is exactly
what such a cycle is waiting for, so hiding it was the wrong priority.

Both waiting banners now clear once the shift is confirmed; while the
rise is only probable, the long-cycle banner stays and its copy says what
lifts it. Tests: long cycle without shift, confirmed, probable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…irmed

Sensiplan closes the fertile window on the evening of the confirming high
day, so that day is both "ovulation confirmed" and "fertile window"; on
the ring the day marker sat exactly on the fertile/luteal seam and the two
labels read as a contradiction. The phase label (ring center, subtitle,
Vitals card) now says "Fertile window · closes tonight" on that day.

Once the shift is confirmed the wide analysis window (from day 4-6 up to
the confirmation) is closed history; on a long cycle it covered 75 of 87
days of the ring and said nothing. `CycleAnalysis.drawnFertileWindow`
narrows what the ring, the BBT chart and the calendar draw to J-5 through
the confirmation day, leaving the analysis itself untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 3-over-6 rule runs on a rolling 3-night median to tame the ring's
0.1 °C quantization, but that smoothing lags the raw series by a day, so
"first higher measurement" and the ovulation estimate landed one day late
against Sensiplan (which dates them from the first raw value above the
line; Heidelberg NFP group: ovulation on average 0.9 days before it, 81 %
within 0–2 days). The estimate now walks back over the raw run above the
coverline (at most the smoothing window); confirmation stays on the
smoothed series, the conservative side.

The chart plotted the raw nightly medians, so a one-quantum dip read as
"back to baseline" on a day the rule still counted as high. The line now
runs through the smoothed series the rule reads; the raw medians stay as
dots, and the caption says which is which.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three things were wrong on a real phone. The cell faded the *whole* view
with `.opacity(0.55)` for future days, which multiplied with each fill's
own opacity: a predicted period day (`cycle.opacity(0.20)`) rendered at
~0.11 and was, in practice, invisible. The fade is gone — a future day is
now dimmed through its digit alone, which already used `textMuted`, so
every shaded fill renders at its designed strength. Predicted flow also
had nothing but a weaker pink to tell it apart from a logged period, so
it now carries a dashed ring in the period color: "expected" reads by
shape, not by tint strength.

The fertile window was drawn at `cycleFertile.opacity(0.12)` and simply
did not show; it is 0.30 now. Both legend swatches were hand-drawn at
different opacities than the grid (0.25 and 0.35), so they could never
match — cells and swatches now come from one `CycleShadingCircle`.

The luteal phase was not drawn at all, although the analyzer knows it.
It now shades from the day after the confirming high — the same test
`CycleAnalyzer` uses to switch the phase, so a merely probable rise still
reads fertile — through the day before the next period, which carries the
band into the future, and for every completed cycle whose shift was
detected. It gets a legend item, hidden with the other fertility markers
under hormonal contraception; the calendar now honours that switch too,
like the chart already did.

The per-day classification moved out of the view into
`CycleCalendarShading`, a pure enum with a total precedence (logged
period > predicted > fertile > luteal > plain) so the view only maps
shading to colors. Tests: predicted days, fertile days, luteal before and
after today, no luteal while probable, a completed cycle's range,
precedence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The calendar only drew forecast flow days when the expected date was
strictly in the future, so on the day the headline reads "Period due
today" no day was shaded at all. Draw the flow whenever the expected
date is today or later; a late period still draws nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@saksham2001

Copy link
Copy Markdown
Owner

Thanks for the PR @henribruvier - this will be a great feature. Keep us posted once you get some data in!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Menstrual cycle tracking (BBT) via Colmi overnight temperature

2 participants