Add menstrual cycle tracking (BBT) from passive overnight temperature - #137
Draft
henribruvier wants to merge 10 commits into
Draft
Add menstrual cycle tracking (BBT) from passive overnight temperature#137henribruvier wants to merge 10 commits into
henribruvier wants to merge 10 commits into
Conversation
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>
Owner
|
Thanks for the PR @henribruvier - this will be a great feature. Keep us posted once you get some data in! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
CycleBBTService) — averages temperature samples taken strictlyinside sleep, so an awake reading or a replayed log can't skew the night.
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.
(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.
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
How was this tested?
PulseLoopTests) — 49 new tests acrossCycleAnalyzerTests,CycleBBTServiceTests,CycleCalendarShadingTests,plus additions to
DataArchiveTests.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.
-seedDemo YES, no hardware) — the demo seed nowgenerates 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
mainbaseline.Privacy & data
Flagging two data-surface changes explicitly, per CONTRIBUTING:
CoachContextPacketgains cycle fields. They travel onlyinside 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.
(
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
if you want the feature listed there.
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