From c12c6a2653f619de20559c17cdbd0cad478864c8 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Thu, 2 Jul 2026 10:28:33 +0200 Subject: [PATCH 01/10] Add menstrual cycle tracking (BBT) from passive overnight temperature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the feature discussed in #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 --- PulseLoop/App/AppTheme.swift | 6 + .../Coach/Context/CoachContextBuilder.swift | 24 + .../Coach/Context/CoachContextPacket.swift | 13 + .../CoachNotificationDelegate.swift | 6 + PulseLoop/DesignSystem/CycleCharts.swift | 140 +++++ PulseLoop/Models/PulseModels.swift | 45 ++ .../Persistence/ModelContainerFactory.swift | 3 +- PulseLoop/Persistence/SeedData.swift | 43 ++ PulseLoop/PulseLoopApp.swift | 4 + PulseLoop/Services/Cycle/CycleAnalyzer.swift | 506 ++++++++++++++++++ .../Services/Cycle/CycleBBTService.swift | 122 +++++ PulseLoop/Services/Cycle/CycleCopy.swift | 85 +++ .../Cycle/CycleNotificationCenter.swift | 108 ++++ PulseLoop/Services/Cycle/CycleService.swift | 123 +++++ PulseLoop/Services/Repositories.swift | 38 ++ PulseLoop/Settings/CycleSettingsStore.swift | 76 +++ PulseLoop/Views/CycleCalendarView.swift | 193 +++++++ PulseLoop/Views/CycleCard.swift | 114 ++++ PulseLoop/Views/CycleDetailView.swift | 425 +++++++++++++++ PulseLoop/Views/RootViews.swift | 4 + .../Views/Settings/CycleSettingsView.swift | 270 ++++++++++ PulseLoop/Views/SettingsView.swift | 17 +- PulseLoop/Views/VitalsView.swift | 5 + PulseLoopTests/CycleAnalyzerTests.swift | 271 ++++++++++ PulseLoopTests/CycleBBTServiceTests.swift | 142 +++++ 25 files changed, 2779 insertions(+), 4 deletions(-) create mode 100644 PulseLoop/DesignSystem/CycleCharts.swift create mode 100644 PulseLoop/Services/Cycle/CycleAnalyzer.swift create mode 100644 PulseLoop/Services/Cycle/CycleBBTService.swift create mode 100644 PulseLoop/Services/Cycle/CycleCopy.swift create mode 100644 PulseLoop/Services/Cycle/CycleNotificationCenter.swift create mode 100644 PulseLoop/Services/Cycle/CycleService.swift create mode 100644 PulseLoop/Settings/CycleSettingsStore.swift create mode 100644 PulseLoop/Views/CycleCalendarView.swift create mode 100644 PulseLoop/Views/CycleCard.swift create mode 100644 PulseLoop/Views/CycleDetailView.swift create mode 100644 PulseLoop/Views/Settings/CycleSettingsView.swift create mode 100644 PulseLoopTests/CycleAnalyzerTests.swift create mode 100644 PulseLoopTests/CycleBBTServiceTests.swift diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 8ffa0682..5e272213 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -25,6 +25,8 @@ enum AppRoute: Hashable { case settingsHealth case settingsStrava case settingsPrivacyData + case settingsCycle + case cycleDetail case settingsAbout case settingsNutrition case nutrition @@ -92,6 +94,10 @@ enum PulseColors { static let bloodPressure = Color(hex: "#FF6B9D") static let bloodSugar = Color(hex: "#FFB84D") static let fatigue = Color(hex: "#C77DFF") + // Cycle tracking (Colmi temperature-based) + static let cycle = Color(hex: "#FF7AA8") + static let cycleFertile = Color(hex: "#4DDCFF") + static let cycleLuteal = Color(hex: "#9D7CFF") // Vitals reference-zone palette. These are the ONLY colors a zone may use, so the chart line, // reference band, gauge arc, stat dot, and status label are always identical for the same zone. static let zoneBlue = Color(hex: "#4DA3FF") // low / cool diff --git a/PulseLoop/Coach/Context/CoachContextBuilder.swift b/PulseLoop/Coach/Context/CoachContextBuilder.swift index 9b7a42af..0f34db7a 100644 --- a/PulseLoop/Coach/Context/CoachContextBuilder.swift +++ b/PulseLoop/Coach/Context/CoachContextBuilder.swift @@ -120,6 +120,7 @@ enum CoachContextBuilder { lastSevenDays: week, latestVitals: vitals, latestSleep: sleep, + cycle: cycleContext(context: context, now: now), recentWorkouts: recentWorkouts(context: context, limit: budget.maxWorkouts), memories: memories(context: context, limit: budget.maxMemories, valueCap: budget.memoryValueCap), conversationSummary: cap(conversationSummary, to: budget.conversationSummaryCap), @@ -166,6 +167,29 @@ enum CoachContextBuilder { return String(text.prefix(limit)) + "…" } + /// Cycle data is the most sensitive thing the app stores: it only enters the coach + /// context behind the dedicated "Share cycle data with the AI Coach" opt-in (off by + /// default), and even then only as a compact summary — never raw logs or notes. + private static func cycleContext(context: ModelContext, now: Date) -> CoachContextPacket.CycleContext? { + let store = CycleSettingsStore.shared + guard store.isActive, store.settings.shareWithCoach else { return nil } + guard let analysis = CycleService.overview(context: context, today: now).analysis else { return nil } + let status: String + switch analysis.ovulation { + case .notDetected: status = "not_detected" + case .probable: status = "probable" + case .confirmed: status = "confirmed" + } + return CoachContextPacket.CycleContext( + cycleDay: analysis.dayNumber, + phase: analysis.phase.rawValue, + ovulationStatus: status, + nextPeriodExpected: analysis.nextPeriod.map { localDate($0.expected) }, + typicalCycleLengthDays: analysis.typicalCycleLengthDays, + note: "Estimates from ring skin temperature — not clinically validated; use cautious wellness language." + ) + } + private static func recentWorkouts(context: ModelContext, limit: Int = 8) -> [CoachContextPacket.WorkoutContext] { ActivityRepository.sessions(context: context) .filter { $0.status == .finished } diff --git a/PulseLoop/Coach/Context/CoachContextPacket.swift b/PulseLoop/Coach/Context/CoachContextPacket.swift index 5bfebc84..43943a26 100644 --- a/PulseLoop/Coach/Context/CoachContextPacket.swift +++ b/PulseLoop/Coach/Context/CoachContextPacket.swift @@ -17,6 +17,9 @@ struct CoachContextPacket: Encodable { var lastSevenDays: WeekContext var latestVitals: VitalsContext var latestSleep: SleepContext? + /// Menstrual-cycle summary. `nil` unless the user explicitly enabled "Share cycle data + /// with the AI Coach" — cycle data must never leave the device as a default. + var cycle: CycleContext? var recentWorkouts: [WorkoutContext] var memories: [MemoryContext] var conversationSummary: String? @@ -100,6 +103,16 @@ struct CoachContextPacket: Encodable { var decoderNote: String } + struct CycleContext: Encodable { + var cycleDay: Int + var phase: String + /// "not_detected" | "probable" | "confirmed" + var ovulationStatus: String + var nextPeriodExpected: String? + var typicalCycleLengthDays: Int? + var note: String + } + struct WorkoutContext: Encodable { var id: String var type: String diff --git a/PulseLoop/Coach/Notifications/CoachNotificationDelegate.swift b/PulseLoop/Coach/Notifications/CoachNotificationDelegate.swift index e55bf35b..a229c374 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationDelegate.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationDelegate.swift @@ -41,6 +41,12 @@ final class CoachNotificationDelegate: NSObject, UNUserNotificationCenterDelegat _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { + // Cycle period-prompt actions ("Yes — log day 1") are handled without opening a thread. + if response.notification.request.content.categoryIdentifier == CycleNotificationCenter.categoryIdentifier { + let action = response.actionIdentifier + await MainActor.run { CycleNotificationCenter.shared.handleAction(identifier: action) } + return + } let info = response.notification.request.content.userInfo if let idString = info[CoachNotificationService.conversationIdKey] as? String, let id = UUID(uuidString: idString) { diff --git a/PulseLoop/DesignSystem/CycleCharts.swift b/PulseLoop/DesignSystem/CycleCharts.swift new file mode 100644 index 00000000..2b81d16e --- /dev/null +++ b/PulseLoop/DesignSystem/CycleCharts.swift @@ -0,0 +1,140 @@ +import SwiftUI +import Charts + +// MARK: - Phase ring + +/// One colored arc of the cycle ring, as fractions of the full turn (0…1, clockwise from top). +struct CyclePhaseSegment: Identifiable { + let id = UUID() + let start: Double + let end: Double + let color: Color +} + +/// The canonical cycle visual: a ring segmented by phase (period / fertile / luteal over a +/// quiet follicular track) with a marker on the current day and free-form center content. +struct CyclePhaseRing: View { + let segments: [CyclePhaseSegment] + /// Current-day position, 0…1 clockwise from top. + let progress: Double + var lineWidth: CGFloat = 14 + @ViewBuilder let center: Center + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + ZStack { + Circle() + .stroke(PulseColors.cardSoft, lineWidth: lineWidth) + ForEach(segments) { segment in + Circle() + .trim(from: segment.start, to: segment.end) + .stroke(segment.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) + .rotationEffect(.degrees(-90)) + } + Circle() + .fill(PulseColors.textPrimary) + .frame(width: lineWidth * 0.62, height: lineWidth * 0.62) + .offset(y: -(side - lineWidth) / 2) + .rotationEffect(.degrees(progress * 360)) + center + } + .frame(width: side, height: side) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - BBT chart + +/// The basal-temperature chart for one cycle: nightly medians joined by a line, excluded +/// (disturbed) nights as hollow points off the line, the coverline as a dashed rule, and the +/// fertile window as a soft band. Values are stored °C and converted for display only. +struct CycleBBTChart: View { + let days: [CycleChartDay] + let coverline: Double? // °C + let fertileWindow: ClosedRange? + let units: UnitsPreference + + private func display(_ celsius: Double) -> Double { + units == .metric ? celsius : celsius * 9 / 5 + 32 + } + + private var validDays: [CycleChartDay] { days.filter { $0.temperature != nil && !$0.excluded } } + private var excludedDays: [CycleChartDay] { days.filter { $0.temperature != nil && $0.excluded } } + + private var yDomain: ClosedRange { + let values = days.compactMap(\.temperature).map(display) + let pad = units == .metric ? 0.2 : 0.4 + guard let lo = values.min(), let hi = values.max() else { + return units == .metric ? 35.0...37.5 : 95.0...99.5 + } + var lower = lo - pad + var upper = hi + pad + if let coverline { + lower = min(lower, display(coverline) - pad) + upper = max(upper, display(coverline) + pad) + } + return lower...upper + } + + var body: some View { + Chart { + if let fertileWindow { + RectangleMark( + xStart: .value("Fertile start", fertileWindow.lowerBound), + xEnd: .value("Fertile end", fertileWindow.upperBound.addingTimeInterval(24 * 3600)) + ) + .foregroundStyle(PulseColors.cycleFertile.opacity(0.08)) + } + ForEach(validDays) { day in + LineMark(x: .value("Day", day.date), y: .value("Temp", display(day.temperature ?? 0))) + .foregroundStyle(PulseColors.cycle) + .interpolationMethod(.monotone) + .lineStyle(StrokeStyle(lineWidth: 2)) + PointMark(x: .value("Day", day.date), y: .value("Temp", display(day.temperature ?? 0))) + .foregroundStyle(PulseColors.cycle) + .symbolSize(26) + } + ForEach(excludedDays) { day in + PointMark(x: .value("Day", day.date), y: .value("Temp", display(day.temperature ?? 0))) + .foregroundStyle(.clear) + .symbol { + Circle() + .strokeBorder(PulseColors.warning, lineWidth: 1.5) + .frame(width: 8, height: 8) + } + } + if let coverline { + RuleMark(y: .value("Coverline", display(coverline))) + .foregroundStyle(PulseColors.textMuted) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 4])) + .annotation(position: .topTrailing, alignment: .trailing) { + Text("Coverline") + .font(.system(size: 9)) + .foregroundStyle(PulseColors.textMuted) + } + } + } + .chartYScale(domain: yDomain) + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: 7)) { _ in + AxisGridLine().foregroundStyle(.clear) + AxisTick().foregroundStyle(.clear) + AxisValueLabel(format: .dateTime.day().month(.abbreviated)) + .font(.system(size: 10)) + .foregroundStyle(PulseColors.textMuted) + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: .automatic(desiredCount: 4)) { _ in + AxisGridLine().foregroundStyle(PulseColors.borderSubtle) + AxisTick().foregroundStyle(.clear) + AxisValueLabel(format: FloatingPointFormatStyle.number.precision(.fractionLength(1))) + .font(.system(size: 10)) + .foregroundStyle(PulseColors.textMuted) + } + } + .frame(height: 210) + } +} diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index f33b14fd..aa88d3cd 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -934,3 +934,48 @@ final class CoachToolCall { self.createdAt = Date() } } + +/// One calendar day of user-logged cycle facts. Deliberately minimal: only what the user +/// asserts (period day, disturbed night, note) is stored — cycle starts, phases, coverline, +/// ovulation, and predictions are all *derived* by `CycleAnalyzer` so they can never drift +/// out of sync with the source facts. Highly sensitive data: never leaves the device and is +/// excluded from coach context and diagnostics exports unless the user explicitly opts in. +@Model +final class CycleDay { + #Index([\.date]) + /// "yyyy-MM-dd" in the local calendar at write time, used as the uniqueness key so a day + /// can only ever have one row (upserts by key, no duplicate-day bugs across timezones). + @Attribute(.unique) var dateString: String + var date: Date // normalized startOfDay + var isPeriod: Bool // a flow day (day 1 is derived, not stored) + var isDisturbed: Bool // night excluded from temperature analysis (fever, alcohol…) + var disturbedAutoDetected: Bool // exclusion was suggested by the app, not typed by the user + var notes: String? + var updatedAt: Date + + /// Fixed-locale key formatter: `en_US_POSIX` + the current timezone so the key always + /// matches the local `startOfDay` the row stores, and never shifts under non-Gregorian + /// user calendars. Static — `DateFormatter` allocation is too costly per init. + static let keyFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static func key(for date: Date) -> String { + keyFormatter.string(from: date) + } + + init(date: Date, isPeriod: Bool = false, isDisturbed: Bool = false, disturbedAutoDetected: Bool = false, notes: String? = nil) { + let day = Calendar.current.startOfDay(for: date) + self.date = day + self.dateString = Self.key(for: day) + self.isPeriod = isPeriod + self.isDisturbed = isDisturbed + self.disturbedAutoDetected = disturbedAutoDetected + self.notes = notes + self.updatedAt = Date() + } +} diff --git a/PulseLoop/Persistence/ModelContainerFactory.swift b/PulseLoop/Persistence/ModelContainerFactory.swift index 6eef7a21..89a9cc9f 100644 --- a/PulseLoop/Persistence/ModelContainerFactory.swift +++ b/PulseLoop/Persistence/ModelContainerFactory.swift @@ -28,7 +28,8 @@ enum ModelContainerFactory { CoachSummary.self, WearableLog.self, MealEntry.self, - CachedFoodProduct.self + CachedFoodProduct.self, + CycleDay.self ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: inMemory) diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 145cfd46..7103a3be 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -101,6 +101,7 @@ enum SeedData { for block in blocks { context.insert(SleepStageBlock(sessionId: session.id, startAt: block.startAt, startMinute: block.startMinute, durationMinutes: block.durationMinutes, stage: block.stage)) } + seedOvernightTemperature(context, nightIndex: i, startAt: startAt, endAt: wake, calendar: calendar) // A few recent days also get daytime nap(s) — separate sessions sharing the same waking // day — so the Day-view sleep carousel (issue #59) has multiple sessions to page through. @@ -124,6 +125,8 @@ enum SeedData { } } + seedCycleDays(context, now: now, calendar: calendar) + // Several finished workouts across recent days (one today). let workouts: [SeedWorkout] = [ SeedWorkout(offset: 0, type: "run", minutes: 38, distance: 6100, calories: 330, origin: (40.4443, -79.9436)), // CMU / Pittsburgh @@ -225,6 +228,45 @@ enum SeedData { } } + /// Overnight skin temperature at the ring's real ~30 min cadence, shaped as a plausible + /// biphasic cycle so the demo shows the BBT chart with a visible thermal shift: cycle day 1 + /// is 27 days ago, the shift lands around day 14, and one feverish night (i == 20) spikes + /// well above baseline to exercise the disturbed-night suggestion. Deterministic, 0.1 °C + /// quantized like the real decoder output. + @MainActor + private static func seedOvernightTemperature( + _ context: ModelContext, nightIndex i: Int, startAt: Date, endAt: Date, calendar: Calendar + ) { + let postOvulatory = i <= 13 // nights after the demo shift run warm + let base = postOvulatory ? 36.25 : 35.85 + let fever = i == 20 ? 0.7 : 0.0 + var ts = startAt + var sample = 0 + while ts <= endAt { + let wobble = sin(Double(sample) * 1.3 + Double(i)) * 0.08 + let value = ((base + fever + wobble) * 10).rounded() / 10 + context.insert(Measurement(kind: .temperature, value: value, unit: "°C", timestamp: ts, source: .mock)) + ts = calendar.date(byAdding: .minute, value: 30, to: ts) ?? endAt.addingTimeInterval(1) + sample += 1 + } + } + + /// Demo period logs matching the seeded temperatures: one completed 28-day cycle and the + /// current cycle started 27 days ago — so predictions, history, and the calendar all have + /// something to show the moment cycle tracking is switched on. + @MainActor + private static func seedCycleDays(_ context: ModelContext, now: Date, calendar: Calendar) { + let today = calendar.startOfDay(for: now) + for offset in -55...(-51) { + guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { continue } + context.insert(CycleDay(date: date, isPeriod: true)) + } + for offset in -27...(-23) { + guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { continue } + context.insert(CycleDay(date: date, isPeriod: true)) + } + } + /// Seeds every vital's measurement history for the demo. Each series is deterministic (no RNG) and /// deliberately walks through its threshold zones — including over-threshold extremes — so the /// zone-colored charts show their full color range. HR/SpO₂ are dense over the last 24h (the @@ -348,6 +390,7 @@ enum SeedData { deleteAll(CoachToolCall.self, context) deleteAll(MealEntry.self, context) deleteAll(CachedFoodProduct.self, context) + deleteAll(CycleDay.self, context) try? context.save() } diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 2c97457c..baedee6b 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -137,6 +137,8 @@ struct PulseLoopApp: App { CoachNotificationScheduler.shared.register { syncBudget in CoachNotificationService(modelContext: ctx, coordinator: coordinator, syncWaitTimeout: syncBudget) } + // Cycle period-prompt notifications: register the action category + context access. + CycleNotificationCenter.shared.register { ctx } } var body: some Scene { @@ -175,6 +177,8 @@ struct PulseLoopApp: App { // scheduler gates on `coachMasterEnabled`, and `runDueSlot` short // -circuits via the feature-flags gate. CoachNotificationScheduler.shared.scheduleNext() + // Cycle period prompt: no-op unless cycle tracking is enabled with a prediction. + CycleNotificationCenter.shared.scheduleNext() guard CoachSettingsStore.shared.settings.coachMasterEnabled else { return } // Foreground catch-up: deliver a due check-in we missed while away. let ctx = container.mainContext diff --git a/PulseLoop/Services/Cycle/CycleAnalyzer.swift b/PulseLoop/Services/Cycle/CycleAnalyzer.swift new file mode 100644 index 00000000..10a511c0 --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleAnalyzer.swift @@ -0,0 +1,506 @@ +import Foundation + +// MARK: - Inputs + +/// Everything the analyzer needs to know about one calendar day. Built by `CycleService` +/// from user-logged `CycleDay` facts + `CycleBBTService` nightly temperatures. +struct CycleDayRecord: Equatable { + var date: Date // normalized startOfDay + var temperature: Double? // nightly BBT °C, nil = no usable night + var isPeriod: Bool + var isDisturbed: Bool + + /// A day whose temperature may participate in the thermal-shift analysis. + var isValidTemperature: Bool { temperature != nil && !isDisturbed } +} + +/// Why the user tracks. Changes display emphasis and how cautiously the fertile window is +/// drawn — never the underlying detection. `avoid` is deliberately supported but the UI +/// wraps it in explicit "this is not contraception" warnings. +enum CycleGoal: String, Codable, CaseIterable, Identifiable { + case understand + case conceive + case avoid + + var id: String { rawValue } + + var label: String { + switch self { + case .understand: return "Understand my cycle" + case .conceive: return "Trying to conceive" + case .avoid: return "Avoid pregnancy" + } + } +} + +/// Tunable knobs of the detection. Defaults reflect *skin* temperature from a finger ring: +/// damped and noisier than the oral BBT the clinical Sensiplan rules were written for, so +/// the rise threshold is an engineering parameter — explicitly not the clinical 0.2 °C. +struct CycleAnalyzerConfig { + /// Required excess of the confirming high over the coverline (on smoothed values). + var risingDelta: Double = 0.15 + /// Consecutive valid low days that establish the coverline. + var referenceDays: Int = 6 + /// Never look for a rise this early in the cycle (menstruation temps are unreliable). + var minimumCycleDay: Int = 5 + /// Nightly temperature this far above the recent baseline suggests fever/disturbance. + var disturbanceExcess: Double = 0.5 + /// Luteal-phase length bounds used when deriving the personal median. + var lutealRange: ClosedRange = 10...16 + var defaultLutealDays: Int = 14 + /// `avoid` goal: widen the displayed fertile window by this many days on each side. + var conservativeOpenLeadDays: Int = 2 + var conservativeCloseLagDays: Int = 1 +} + +// MARK: - Outputs + +enum CyclePhase: String { + case menstruation + case follicular + case fertile + case luteal + + var label: String { + switch self { + case .menstruation: return "Period" + case .follicular: return "Follicular" + case .fertile: return "Fertile window" + case .luteal: return "Luteal" + } + } +} + +enum OvulationStatus: Equatable { + case notDetected + /// A rise is underway but the 3-over-6 rule hasn't fully confirmed yet. + case probable(estimated: Date) + /// The rule (with its exceptions) is satisfied. + case confirmed(estimated: Date, confirmedOn: Date) + + var estimatedDate: Date? { + switch self { + case .notDetected: return nil + case let .probable(estimated), let .confirmed(estimated, _): return estimated + } + } + + var isConfirmed: Bool { + if case .confirmed = self { return true } + return false + } +} + +struct PeriodPrediction: Equatable { + var expected: Date + var earliest: Date + var latest: Date +} + +struct CompletedCycleSummary: Equatable, Identifiable { + var start: Date + var lengthDays: Int + /// Days from cycle start to the estimated ovulation, when a shift was detected. + var ovulationDayIndex: Int? + + var id: Date { start } + var lutealDays: Int? { ovulationDayIndex.map { lengthDays - $0 } } +} + +enum CycleFlag: Equatable { + /// ≥ 18 days of sustained high temperature after a confirmed shift and no period — + /// worth suggesting a pregnancy test, phrased neutrally. + case possiblePregnancy + /// Late in the cycle with no shift detected. Anovulatory cycles happen; say so honestly. + case noThermalShiftYet + /// Very long open cycle — stop pretending the numbers mean much. + case longCycle +} + +struct CycleAnalysis: Equatable { + var cycleStart: Date + var dayNumber: Int // 1-based, for `today` + var phase: CyclePhase + var coverline: Double? + var ovulation: OvulationStatus + var fertileWindow: ClosedRange? + var nextPeriod: PeriodPrediction? + var completedCycles: [CompletedCycleSummary] + var typicalCycleLengthDays: Int? + var lutealLengthDays: Int + var flags: [CycleFlag] + /// Most recent night that looks like fever/disturbance and isn't excluded yet — the UI + /// offers a one-tap "exclude this night?" instead of expecting the user to remember. + var disturbanceSuggestion: Date? +} + +// MARK: - Analyzer + +/// Pure, deterministic cycle analysis: Sensiplan's 3-over-6 mechanics (with both classical +/// exceptions) applied to *baseline-relative smoothed skin temperature*, plus next-period / +/// fertile-window estimation from personal history. No I/O, no clock reads, no globals — +/// everything comes in through the arguments so the whole thing is trivially unit-testable. +enum CycleAnalyzer { + // MARK: Entry point + + /// `days` must be sorted by date and cover (at most) one record per day. Returns `nil` + /// until the user has logged at least one period day at or before `today`. + static func analyze( + days: [CycleDayRecord], + goal: CycleGoal, + today: Date, + calendar: Calendar = .current, + config: CycleAnalyzerConfig = CycleAnalyzerConfig() + ) -> CycleAnalysis? { + let today = calendar.startOfDay(for: today) + let starts = periodStarts(days: days, calendar: calendar).filter { $0 <= today } + guard let currentStart = starts.last else { return nil } + + // History: each pair of consecutive starts closes a cycle; detect its shift for luteal stats. + var completed: [CompletedCycleSummary] = [] + for (start, next) in zip(starts, starts.dropFirst()) { + let length = daysBetween(start, next, calendar: calendar) + let cycleRecords = records(days, from: start, before: next) + let shift = detectShift(in: cycleRecords, cycleStart: start, calendar: calendar, config: config) + let ovulationIndex = shift?.status.estimatedDate.map { daysBetween(start, $0, calendar: calendar) } + completed.append(CompletedCycleSummary(start: start, lengthDays: length, ovulationDayIndex: ovulationIndex)) + } + + let currentRecords = records(days, from: currentStart, before: calendar.date(byAdding: .day, value: 1, to: today) ?? today) + let shift = detectShift(in: currentRecords, cycleStart: currentStart, calendar: calendar, config: config) + let dayNumber = daysBetween(currentStart, today, calendar: calendar) + 1 + + let luteal = lutealLength(completed: completed, config: config) + let typicalLength = typicalCycleLength(completed: completed) + let prediction = nextPeriodPrediction( + cycleStart: currentStart, ovulation: shift?.status, lutealDays: luteal, + completed: completed, calendar: calendar + ) + let window = fertileWindow( + cycleStart: currentStart, ovulation: shift?.status, prediction: prediction, + lutealDays: luteal, completed: completed, goal: goal, calendar: calendar, config: config + ) + let phase = phase( + today: today, records: currentRecords, ovulation: shift?.status, + fertileWindow: window, calendar: calendar + ) + + return CycleAnalysis( + cycleStart: currentStart, + dayNumber: dayNumber, + phase: phase, + coverline: shift?.coverline, + ovulation: shift?.status ?? .notDetected, + fertileWindow: window, + nextPeriod: prediction, + completedCycles: completed, + typicalCycleLengthDays: typicalLength, + lutealLengthDays: luteal, + flags: flags(dayNumber: dayNumber, records: currentRecords, shift: shift, calendar: calendar), + disturbanceSuggestion: disturbanceSuggestion(records: currentRecords, shift: shift, config: config) + ) + } + + // MARK: Cycle boundaries + + /// Derived day-1s: a period day counts as a new cycle start when the previous period day + /// is more than 3 days earlier (so a spotting gap inside one period doesn't split it). + static func periodStarts(days: [CycleDayRecord], calendar: Calendar = .current) -> [Date] { + var starts: [Date] = [] + var previousPeriodDay: Date? + for record in days where record.isPeriod { + if let previous = previousPeriodDay { + if daysBetween(previous, record.date, calendar: calendar) > 3 { + starts.append(record.date) + } + } else { + starts.append(record.date) + } + previousPeriodDay = record.date + } + return starts + } + + private static func records(_ days: [CycleDayRecord], from start: Date, before end: Date) -> [CycleDayRecord] { + days.filter { $0.date >= start && $0.date < end } + } + + // MARK: Thermal shift (Sensiplan 3-over-6 mechanics on smoothed values) + + struct ShiftResult: Equatable { + var coverline: Double + var firstHighDay: Date + var status: OvulationStatus + } + + /// Scan one cycle's records for a sustained temperature rise. Values are first smoothed + /// (rolling median over the last 3 *valid* days) to tame the ring's 0.1 °C quantization, + /// then the classical rule runs on the smoothed series: + /// - coverline = max of the 6 valid days before the first raised value; + /// - 3 consecutive valid values above the coverline, the 3rd ≥ coverline + delta; + /// - exception 1: a weak 3rd (above but < delta) is rescued by a 4th above the line; + /// - exception 2: one dip to/below the line among the 2nd/3rd is discarded, and the + /// replacement value must clear coverline + delta (exceptions never combine). + static func detectShift( + in cycleRecords: [CycleDayRecord], + cycleStart: Date, + calendar: Calendar = .current, + config: CycleAnalyzerConfig = CycleAnalyzerConfig() + ) -> ShiftResult? { + let valid = cycleRecords.filter(\.isValidTemperature) + guard valid.count > config.referenceDays else { return nil } + let smoothed = smoothedValues(valid) + + var pending: ShiftResult? + for candidate in config.referenceDays..= config.minimumCycleDay else { continue } + let coverline = smoothed[(candidate - config.referenceDays).. coverline else { continue } + + let estimated = calendar.date(byAdding: .day, value: -1, to: valid[candidate].date) ?? valid[candidate].date + switch evaluateRise(candidate: candidate, coverline: coverline, values: smoothed, config: config) { + case let .confirmed(index): + return ShiftResult( + coverline: coverline, + firstHighDay: valid[candidate].date, + status: .confirmed(estimated: estimated, confirmedOn: valid[index].date) + ) + case let .pending(highs): + // Rise underway at the end of the data. One raised value is noise; from two + // consecutive highs we surface it as "probable". Remember the earliest. + if highs >= 2, pending == nil { + pending = ShiftResult(coverline: coverline, firstHighDay: valid[candidate].date, + status: .probable(estimated: estimated)) + } + case .failed: + continue + } + } + return pending + } + + enum RiseEvaluation: Equatable { + case confirmed(finalIndex: Int) + case pending(highs: Int) + case failed + } + + /// Walk the values after a candidate first-high and apply the 3-high rule + exceptions. + /// Internal (not private) so the exception mechanics are unit-testable without having to + /// reverse-engineer sequences through the smoothing. + static func evaluateRise(candidate: Int, coverline: Double, values: [Double], config: CycleAnalyzerConfig) -> RiseEvaluation { + var highs = 1 // values above the coverline collected so far + var dipUsed = false // exception 2 spent + var requiresFullDelta = false // after a dip, the closer must clear the full delta + var index = candidate + 1 + + while index < values.count { + let value = values[index] + if value > coverline { + highs += 1 + let clearsDelta = value >= coverline + config.risingDelta + if highs >= 3 { + if clearsDelta { return .confirmed(finalIndex: index) } + if requiresFullDelta { return .failed } // exceptions don't combine + // Exception 1: weak 3rd — a 4th above the line (any amount) confirms. + if highs >= 4 { return .confirmed(finalIndex: index) } + } + } else { + // A value on/below the line among the highs: one is forgiven (exception 2), + // a second kills the candidate. + if dipUsed { return .failed } + dipUsed = true + requiresFullDelta = true + } + index += 1 + } + return .pending(highs: highs) + } + + /// Rolling median over the last (up to) 3 valid values — quantization + outlier damping. + static func smoothedValues(_ valid: [CycleDayRecord]) -> [Double] { + valid.indices.map { index in + let window = valid[max(0, index - 2)...index].compactMap(\.temperature) + return CycleBBTService.median(window) + } + } + + // MARK: Personal statistics + + private static func lutealLength(completed: [CompletedCycleSummary], config: CycleAnalyzerConfig) -> Int { + let lengths = completed.compactMap(\.lutealDays).suffix(6) + guard !lengths.isEmpty else { return config.defaultLutealDays } + let median = Int(CycleBBTService.median(lengths.map(Double.init)).rounded()) + return min(max(median, config.lutealRange.lowerBound), config.lutealRange.upperBound) + } + + private static func typicalCycleLength(completed: [CompletedCycleSummary]) -> Int? { + let lengths = completed.suffix(6).map(\.lengthDays) + guard !lengths.isEmpty else { return nil } + return Int(CycleBBTService.median(lengths.map(Double.init)).rounded()) + } + + // MARK: Predictions + + private static func nextPeriodPrediction( + cycleStart: Date, + ovulation: OvulationStatus?, + lutealDays: Int, + completed: [CompletedCycleSummary], + calendar: Calendar + ) -> PeriodPrediction? { + // Confirmed ovulation pins the prediction: luteal length is the stable half of a cycle. + if case let .confirmed(estimated, _) = ovulation, + let expected = calendar.date(byAdding: .day, value: lutealDays, to: estimated) { + return prediction(around: expected, spreadDays: 1, calendar: calendar) + } + // Otherwise fall back to cycle-length statistics; cycle 1 stays honestly silent. + guard let typicalLength = typicalCycleLength(completed: completed), + let expected = calendar.date(byAdding: .day, value: typicalLength, to: cycleStart) else { return nil } + let lengths = completed.suffix(6).map { Double($0.lengthDays) } + var spread = 2 + if lengths.count >= 3 { + let mean = lengths.reduce(0, +) / Double(lengths.count) + let std = (lengths.reduce(0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lengths.count)).squareRoot() + spread = max(2, Int(std.rounded())) + } + return prediction(around: expected, spreadDays: spread, calendar: calendar) + } + + private static func prediction(around expected: Date, spreadDays: Int, calendar: Calendar) -> PeriodPrediction { + PeriodPrediction( + expected: expected, + earliest: calendar.date(byAdding: .day, value: -spreadDays, to: expected) ?? expected, + latest: calendar.date(byAdding: .day, value: spreadDays, to: expected) ?? expected + ) + } + + // swiftlint:disable:next function_parameter_count + private static func fertileWindow( + cycleStart: Date, + ovulation: OvulationStatus?, + prediction: PeriodPrediction?, + lutealDays: Int, + completed: [CompletedCycleSummary], + goal: CycleGoal, + calendar: Calendar, + config: CycleAnalyzerConfig + ) -> ClosedRange? { + // Opening, most personal rule available first: + // - Sensiplan minus-8: earliest first-high cycle day across past cycles − 8; + // - Döring fallback: shortest cycle length − 20; + // - no history: day 6. + // Never earlier than day 6 (period + the first infertile days), except in `avoid`. + let recent = completed.suffix(6) + let earliestOvulationIndex = recent.compactMap(\.ovulationDayIndex).min() + let shortest = recent.map(\.lengthDays).min() + var openDayIndex: Int + if let earliestOvulationIndex { + openDayIndex = max(6, earliestOvulationIndex + 2 - 8) + } else if let shortest { + openDayIndex = max(6, shortest - 20) + } else { + openDayIndex = 6 + } + + var close: Date? + switch ovulation { + case let .confirmed(_, confirmedOn): + // Sensiplan closes the fertile window on the evening of the confirming high day. + close = confirmedOn + case let .probable(estimated): + close = calendar.date(byAdding: .day, value: 1, to: estimated) + case .notDetected, nil: + // No rise in sight: estimate ovulation backwards from the predicted period. + guard let prediction else { return nil } + close = calendar.date(byAdding: .day, value: -(lutealDays - 1), to: prediction.expected) + } + guard var closeDate = close else { return nil } + + if goal == .avoid { + openDayIndex = max(1, openDayIndex - config.conservativeOpenLeadDays) + if !(ovulation?.isConfirmed ?? false) { + closeDate = calendar.date(byAdding: .day, value: config.conservativeCloseLagDays, to: closeDate) ?? closeDate + } + } + + guard let openDate = calendar.date(byAdding: .day, value: openDayIndex - 1, to: cycleStart), + openDate <= closeDate else { return nil } + return openDate...closeDate + } + + // MARK: Phase & flags + + private static func phase( + today: Date, + records: [CycleDayRecord], + ovulation: OvulationStatus?, + fertileWindow: ClosedRange?, + calendar: Calendar + ) -> CyclePhase { + if records.contains(where: { calendar.isDate($0.date, inSameDayAs: today) && $0.isPeriod }) { + return .menstruation + } + // The infertile luteal phase only begins once the shift is *confirmed* — until then a + // passed estimated window stays "fertile" (the safe reading, and the honest one). + if case let .confirmed(_, confirmedOn) = ovulation, today > confirmedOn { + return .luteal + } + if let fertileWindow, fertileWindow.contains(today) { + return .fertile + } + if let fertileWindow, today > fertileWindow.upperBound, ovulation?.isConfirmed != true { + return .fertile + } + return .follicular + } + + private static func flags( + dayNumber: Int, + records: [CycleDayRecord], + shift: ShiftResult?, + calendar: Calendar + ) -> [CycleFlag] { + var flags: [CycleFlag] = [] + if case let .confirmed(estimated, _) = shift?.status, + let shiftResult = shift, + let lastValid = records.last(where: \.isValidTemperature), + daysBetween(estimated, lastValid.date, calendar: calendar) >= 18, + let temperature = lastValid.temperature, + temperature > shiftResult.coverline { + flags.append(.possiblePregnancy) + } + if dayNumber > 60 { + flags.append(.longCycle) + } else if dayNumber >= 35, shift?.status.isConfirmed != true { + flags.append(.noThermalShiftYet) + } + return flags + } + + /// The most recent unexcluded night whose raw temperature sits well above the recent + /// baseline — likely fever/alcohol, so the UI can offer a one-tap exclusion. Skipped in + /// the confirmed post-ovulatory phase, where a high plateau is expected and healthy. + private static func disturbanceSuggestion( + records: [CycleDayRecord], + shift: ShiftResult?, + config: CycleAnalyzerConfig + ) -> Date? { + guard let last = records.last(where: { $0.temperature != nil }), !last.isDisturbed else { return nil } + if case .confirmed = shift?.status, last.date >= (shift?.firstHighDay ?? last.date) { return nil } + let history = records + .filter { $0.isValidTemperature && $0.date < last.date } + .suffix(7) + .compactMap(\.temperature) + guard history.count >= 3, let temperature = last.temperature else { return nil } + let baseline = CycleBBTService.median(history) + return temperature > baseline + config.disturbanceExcess ? last.date : nil + } + + // MARK: Helpers + + static func daysBetween(_ from: Date, _ to: Date, calendar: Calendar = .current) -> Int { + calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day ?? 0 + } +} diff --git a/PulseLoop/Services/Cycle/CycleBBTService.swift b/PulseLoop/Services/Cycle/CycleBBTService.swift new file mode 100644 index 00000000..bebde8dd --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleBBTService.swift @@ -0,0 +1,122 @@ +import Foundation +import SwiftData + +/// One night's basal-temperature estimate for cycle analysis. +struct CycleNightTemperature: Equatable { + /// The waking-morning day the night belongs to (same keying as `SleepSession.date`). + let date: Date + /// Median of the ring's overnight skin-temperature samples, °C. `nil` when the night + /// produced too few samples to trust. + let celsius: Double? + let sampleCount: Int +} + +/// Extracts a nightly basal body temperature (BBT) from the ring's passive skin-temperature +/// history. The ring samples every ~30 min; a night therefore yields ~10–16 points. We take +/// the **median** of the samples inside the sleep session (excluding awake blocks) rather +/// than the mean: samples are quantized to 0.1 °C and a hand outside the blanket produces +/// low outliers the median shrugs off. +@MainActor +enum CycleBBTService { + /// Fewer overnight samples than this and the night is reported as "no data" — a couple + /// of stray readings say nothing about basal temperature. + static let minimumSamples = 4 + + /// Nightly temperatures for every day in `days` (inclusive, newest last). Days without a + /// usable night are still present with `celsius == nil` so charts can show gaps honestly. + /// Sessions and awake blocks are fetched once for the whole span — never per night. + static func nightlyTemperatures(days: [Date], context: ModelContext) -> [CycleNightTemperature] { + guard !days.isEmpty else { return [] } + let calendar = Calendar.current + let sessions = SleepRepository.sessions(context: context) + let awakeRaw = SleepStage.awake.rawValue + let awakeBlocks = (try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.stageRaw == awakeRaw } + ))) ?? [] + let awakeBySession = Dictionary(grouping: awakeBlocks, by: \.sessionId) + return days.map { day in + nightTemperature(for: calendar.startOfDay(for: day), sessions: sessions, awakeBySession: awakeBySession, context: context) + } + } + + /// The basal temperature for the night that ended on the morning of `day`. + static func nightTemperature(for day: Date, context: ModelContext) -> CycleNightTemperature { + nightlyTemperatures(days: [day], context: context)[0] + } + + private static func nightTemperature( + for day: Date, + sessions: [SleepSession], + awakeBySession: [UUID: [SleepStageBlock]], + context: ModelContext + ) -> CycleNightTemperature { + let calendar = Calendar.current + if let session = sessions.first(where: { calendar.isDate($0.date, inSameDayAs: day) }) { + let awake = (awakeBySession[session.id] ?? []) + .map { block -> ClosedRange in + block.startAt...(block.startAt.addingTimeInterval(TimeInterval(block.durationMinutes * 60))) + } + let samples = MetricsRepository.measurements( + kind: .temperature, start: session.startAt, end: session.endAt, context: context + ) + .filter { sample in !awake.contains { $0.contains(sample.timestamp) } } + .map(\.value) + .filter { $0 > 0 } + return night(day: day, values: samples) + } + // No sleep session detected (missed sync, unusual schedule). Fall back to the most + // *stable* stretch of temperature in the surrounding 24 h: skin temperature during + // rest is steady, daytime readings are noisy — so the lowest-variance 4 h window is + // the best guess at the rest period. A fixed clock window (e.g. 2–5 AM) would be + // wrong for night-shift users. + return stableWindowFallback(for: day, context: context) + } + + private static func stableWindowFallback(for day: Date, context: ModelContext) -> CycleNightTemperature { + let calendar = Calendar.current + // Noon-to-noon around the waking morning, mirroring the sleep grouping boundary. + guard let windowStart = calendar.date(byAdding: .hour, value: -12, to: day), + let windowEnd = calendar.date(byAdding: .hour, value: 12, to: day) else { + return CycleNightTemperature(date: day, celsius: nil, sampleCount: 0) + } + let samples = MetricsRepository.measurements(kind: .temperature, start: windowStart, end: windowEnd, context: context) + .filter { $0.value > 0 } + .sorted { $0.timestamp < $1.timestamp } + // 4 h at the ring's ~30 min cadence ⇒ 8 samples per window; require enough for a median. + let windowSize = 8 + guard samples.count >= max(windowSize, minimumSamples) else { + return CycleNightTemperature(date: day, celsius: nil, sampleCount: 0) + } + var best: (variance: Double, values: [Double])? + for start in 0...(samples.count - windowSize) { + let slice = samples[start..<(start + windowSize)] + // Reject windows spanning a data gap — "contiguous" means the cadence held. + let span = slice.last!.timestamp.timeIntervalSince(slice.first!.timestamp) + guard span <= TimeInterval(windowSize) * 45 * 60 else { continue } + let values = slice.map(\.value) + let mean = values.reduce(0, +) / Double(values.count) + let variance = values.reduce(0) { $0 + ($1 - mean) * ($1 - mean) } / Double(values.count) + if best == nil || variance < best!.variance { + best = (variance, values) + } + } + guard let best else { return CycleNightTemperature(date: day, celsius: nil, sampleCount: 0) } + return night(day: day, values: best.values) + } + + private static func night(day: Date, values: [Double]) -> CycleNightTemperature { + guard values.count >= minimumSamples else { + return CycleNightTemperature(date: day, celsius: nil, sampleCount: values.count) + } + return CycleNightTemperature(date: day, celsius: median(values), sampleCount: values.count) + } + + static func median(_ values: [Double]) -> Double { + let sorted = values.sorted() + let mid = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] + } +} diff --git a/PulseLoop/Services/Cycle/CycleCopy.swift b/PulseLoop/Services/Cycle/CycleCopy.swift new file mode 100644 index 00000000..529f2c10 --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleCopy.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Status copy shared by the Vitals card and the detail header. Pure so the priority order +/// (period > pregnancy hint > confirmation > rise > countdown > phase) is unit-testable. +enum CycleCopy { + /// The single most useful line for "where am I?" — countdown first, jargon last. + static func headline( + _ analysis: CycleAnalysis, + hormonal: Bool, + today: Date = Date(), + calendar: Calendar = .current + ) -> String { + let today = calendar.startOfDay(for: today) + if analysis.phase == .menstruation { + return "Period — day \(analysis.dayNumber)" + } + if analysis.flags.contains(.possiblePregnancy) { + return "High temps for 18+ days" + } + if hormonal { + return "Cycle day \(analysis.dayNumber)" + } + if analysis.flags.contains(.longCycle) { + return "Long cycle — waiting for data" + } + if case let .confirmed(_, confirmedOn) = analysis.ovulation, + CycleAnalyzer.daysBetween(confirmedOn, today, calendar: calendar) <= 3 { + return "Ovulation likely confirmed" + } + if let prediction = analysis.nextPeriod { + let delta = CycleAnalyzer.daysBetween(today, prediction.expected, calendar: calendar) + switch delta { + case 2...: return "Period in ~\(delta) days" + case 1: return "Period likely tomorrow" + case 0: return "Period due today" + default: return "Period \(-delta) day\(delta == -1 ? "" : "s") late" + } + } + if case .probable = analysis.ovulation { + return "Temperature rising" + } + if let window = analysis.fertileWindow, window.contains(today) { + return "Fertile window" + } + if analysis.completedCycles.isEmpty { + return "Learning your cycle" + } + return "Cycle day \(analysis.dayNumber)" + } + + /// "Day 14 · Luteal" — the second line under the headline. + static func subtitle(_ analysis: CycleAnalysis, hormonal: Bool) -> String { + hormonal ? "Day \(analysis.dayNumber) · analysis paused" : "Day \(analysis.dayNumber) · \(analysis.phase.label)" + } + + /// Whether to surface the one-tap "My period started" button: around the predicted date + /// (J-3 … onward), late without a prediction, or whenever nothing is known yet. + static func shouldOfferPeriodStart( + _ analysis: CycleAnalysis?, + today: Date = Date(), + calendar: Calendar = .current + ) -> Bool { + guard let analysis else { return true } + let today = calendar.startOfDay(for: today) + if analysis.phase == .menstruation { return false } + if let prediction = analysis.nextPeriod { + return CycleAnalyzer.daysBetween(prediction.expected, today, calendar: calendar) >= -3 + } + return analysis.dayNumber >= 21 + } + + /// Longer explanations for the flag banners on the detail screen. + static func flagMessage(_ flag: CycleFlag) -> String { + switch flag { + case .possiblePregnancy: + return "Your temperature has stayed high for 18+ days after ovulation with no period logged. " + + "A pregnancy test may be worth considering." + case .noThermalShiftYet: + return "No temperature shift detected this cycle so far. Cycles without a clear shift happen " + + "and are usually nothing to worry about." + case .longCycle: + return "This cycle is running unusually long. Estimates are paused until a new period is logged." + } + } +} diff --git a/PulseLoop/Services/Cycle/CycleNotificationCenter.swift b/PulseLoop/Services/Cycle/CycleNotificationCenter.swift new file mode 100644 index 00000000..18c90a29 --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleNotificationCenter.swift @@ -0,0 +1,108 @@ +import Foundation +import SwiftData +import UserNotifications + +/// Schedules the period-prediction notifications and handles their one-tap actions. The +/// "has it started?" prompt with a **Yes, log it** action *is* the feature's one-tap UX: +/// day 1 gets logged from the lock screen without opening the app. +/// +/// Notification copy is deliberately discreet — a lock screen should not broadcast cycle +/// details beyond what the prompt needs. +@MainActor +final class CycleNotificationCenter { + static let shared = CycleNotificationCenter() + + static let categoryIdentifier = "pulseloop.cycle.periodPrompt" + static let actionStarted = "pulseloop.cycle.periodStarted" + static let actionNotYet = "pulseloop.cycle.periodNotYet" + private static let dueRequestIdentifier = "pulseloop.cycle.periodDue" + private static let preAlertRequestIdentifier = "pulseloop.cycle.periodPreAlert" + + /// Set once at app start; the notification delegate has no other path to the store. + private var contextProvider: (() -> ModelContext)? + + func register(contextProvider: @escaping () -> ModelContext) { + self.contextProvider = contextProvider + registerCategory() + } + + private func registerCategory() { + let started = UNNotificationAction(identifier: Self.actionStarted, title: "Yes — log day 1", options: []) + let notYet = UNNotificationAction(identifier: Self.actionNotYet, title: "Not yet", options: []) + let category = UNNotificationCategory( + identifier: Self.categoryIdentifier, + actions: [started, notYet], + intentIdentifiers: [], + options: [] + ) + UNUserNotificationCenter.current().getNotificationCategories { existing in + UNUserNotificationCenter.current().setNotificationCategories(existing.union([category])) + } + } + + /// Re-derive the prediction and (re)schedule the prompt(s). Called on app-active and + /// after any cycle edit; always replaces what was previously pending so a changed + /// prediction never leaves a stale notification behind. + func scheduleNext() { + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [Self.dueRequestIdentifier, Self.preAlertRequestIdentifier]) + + let store = CycleSettingsStore.shared + guard store.isActive, store.settings.periodPromptEnabled, let context = contextProvider?() else { return } + guard CycleService.isAvailable(context: context) else { return } + let overview = CycleService.overview(context: context) + guard let prediction = overview.analysis?.nextPeriod else { return } + + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + if prediction.expected >= today { + schedule( + identifier: Self.dueRequestIdentifier, + on: prediction.expected, + body: "Your period is due around today — has it started?", + withActions: true, + calendar: calendar + ) + } + if store.settings.preAlertEnabled, + let preAlertDay = calendar.date(byAdding: .day, value: -2, to: prediction.expected), + preAlertDay >= today { + schedule( + identifier: Self.preAlertRequestIdentifier, + on: preAlertDay, + body: "Your period is likely in about 2 days.", + withActions: false, + calendar: calendar + ) + } + } + + func cancel() { + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: [Self.dueRequestIdentifier, Self.preAlertRequestIdentifier]) + } + + private func schedule(identifier: String, on day: Date, body: String, withActions: Bool, calendar: Calendar) { + let content = UNMutableNotificationContent() + content.title = "PulseLoop" + content.body = body + content.sound = .default + if withActions { content.categoryIdentifier = Self.categoryIdentifier } + + var components = calendar.dateComponents([.year, .month, .day], from: day) + components.hour = 9 + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + } + + /// Route a notification action. "Yes" writes today's period day straight into the store — + /// the app never needs to open. + func handleAction(identifier: String) { + guard identifier == Self.actionStarted, let context = contextProvider?() else { return } + let day = CycleRepository.dayOrNew(for: Date(), context: context) + day.isPeriod = true + CycleRepository.save(day, context: context) + scheduleNext() + } +} diff --git a/PulseLoop/Services/Cycle/CycleService.swift b/PulseLoop/Services/Cycle/CycleService.swift new file mode 100644 index 00000000..d37d9e1f --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleService.swift @@ -0,0 +1,123 @@ +import Foundation +import SwiftData + +/// One day of the current cycle prepared for the BBT chart. +struct CycleChartDay: Identifiable, Equatable { + let date: Date + let temperature: Double? // °C, nil = gap + let excluded: Bool // disturbed night — drawn hollow, skipped by the analysis + let isPeriod: Bool + + var id: Date { date } +} + +/// Everything the cycle UI needs, computed in one pass off the render path. +struct CycleOverview: Equatable { + var analysis: CycleAnalysis? + /// Current-cycle days for the chart, oldest first (empty until a period is logged). + var chartDays: [CycleChartDay] + /// Days the user flagged (or confirmed) in any way, keyed by `CycleDay.key(for:)` — + /// lets the calendar mark period/disturbed days without refetching. + var loggedDays: [String: CycleDayFacts] + + struct CycleDayFacts: Equatable { + var isPeriod: Bool + var isDisturbed: Bool + var hasNote: Bool + } +} + +/// Glue between storage and the pure analyzer: merges user-logged `CycleDay` facts with +/// `CycleBBTService` nightly temperatures into `CycleDayRecord`s, runs `CycleAnalyzer`, and +/// packages chart/calendar data. All reads, no writes. +@MainActor +enum CycleService { + /// Analysis window cap: sleep/temperature extraction is bounded, and cycles older than + /// this add nothing to luteal statistics (we keep at most the last 6 anyway). + static let maxHistoryDays = 400 + + /// The cycle feature needs a ring that measures temperature; the jring doesn't declare + /// the capability, so the card/settings never appear for it (same gating as elsewhere). + static func isAvailable(context: ModelContext) -> Bool { + MetricsService.deviceCapabilities(context).contains(.temperature) + } + + static func overview(context: ModelContext, today: Date = Date()) -> CycleOverview { + let calendar = Calendar.current + let today = calendar.startOfDay(for: today) + let logged = CycleRepository.days(context: context) + + var loggedFacts: [String: CycleOverview.CycleDayFacts] = [:] + for day in logged { + loggedFacts[day.dateString] = CycleOverview.CycleDayFacts( + isPeriod: day.isPeriod, + isDisturbed: day.isDisturbed, + hasNote: !(day.notes?.isEmpty ?? true) + ) + } + + guard let firstPeriod = logged.first(where: \.isPeriod)?.date else { + return CycleOverview(analysis: nil, chartDays: [], loggedDays: loggedFacts) + } + + let horizon = calendar.date(byAdding: .day, value: -maxHistoryDays, to: today) ?? today + let windowStart = max(calendar.startOfDay(for: firstPeriod), horizon) + let dayCount = CycleAnalyzer.daysBetween(windowStart, today, calendar: calendar) + 1 + let allDays = (0.. CycleDayRecord in + let facts = loggedByKey[CycleDay.key(for: day)] + return CycleDayRecord( + date: day, + temperature: night.celsius, + isPeriod: facts?.isPeriod ?? false, + isDisturbed: facts?.isDisturbed ?? false + ) + } + + let settings = CycleSettingsStore.shared.settings + // Under hormonal contraception the thermal analysis has no biological meaning: keep + // period bookkeeping (cycle day, calendar) but drop every temperature-derived output. + let analysis: CycleAnalysis? + if settings.onHormonalContraception { + analysis = CycleAnalyzer.analyze( + days: records.map { CycleDayRecord(date: $0.date, temperature: nil, isPeriod: $0.isPeriod, isDisturbed: $0.isDisturbed) }, + goal: settings.goal, today: today, calendar: calendar + ) + } else { + analysis = CycleAnalyzer.analyze(days: records, goal: settings.goal, today: today, calendar: calendar) + } + + let chartDays: [CycleChartDay] + if let start = analysis?.cycleStart { + chartDays = records.filter { $0.date >= start }.map { + CycleChartDay(date: $0.date, temperature: $0.temperature, excluded: $0.isDisturbed, isPeriod: $0.isPeriod) + } + } else { + chartDays = [] + } + + return CycleOverview(analysis: analysis, chartDays: chartDays, loggedDays: loggedFacts) + } + + /// Chart/calendar data for an arbitrary past cycle (index into `analysis.completedCycles`). + static func chartDays(for cycle: CompletedCycleSummary, context: ModelContext) -> [CycleChartDay] { + let calendar = Calendar.current + let days = (0.. [CycleDay] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.date)]) + return (try? context.fetch(descriptor)) ?? [] + } + + @MainActor + static func day(for date: Date, context: ModelContext) -> CycleDay? { + let key = CycleDay.key(for: Calendar.current.startOfDay(for: date)) + let descriptor = FetchDescriptor(predicate: #Predicate { $0.dateString == key }) + return (try? context.fetch(descriptor))?.first + } + + /// Fetch the row for `date`, inserting a blank one if none exists yet. Callers mutate the + /// returned row and `save(_:context:)` it. + @MainActor + static func dayOrNew(for date: Date, context: ModelContext) -> CycleDay { + if let existing = day(for: date, context: context) { return existing } + let fresh = CycleDay(date: date) + context.insert(fresh) + return fresh + } + + /// Persist edits, pruning rows that no longer assert anything so the table only ever + /// holds real user facts. + @MainActor + static func save(_ day: CycleDay, context: ModelContext) { + day.updatedAt = Date() + let empty = !day.isPeriod && !day.isDisturbed && (day.notes?.isEmpty ?? true) + if empty { context.delete(day) } + try? context.save() + PulseDataChange.shared.notify() + } +} + enum CoachRepository { @MainActor static func messages(context: ModelContext) -> [CoachMessage] { diff --git a/PulseLoop/Settings/CycleSettingsStore.swift b/PulseLoop/Settings/CycleSettingsStore.swift new file mode 100644 index 00000000..4122e834 --- /dev/null +++ b/PulseLoop/Settings/CycleSettingsStore.swift @@ -0,0 +1,76 @@ +import Foundation + +/// User-tunable cycle-tracking configuration, persisted as JSON in `UserDefaults` (same +/// pattern as `CoachSettings`). The health facts themselves live in SwiftData (`CycleDay`); +/// this is only preferences. +struct CycleSettings: Codable, Equatable { + /// Master switch. Off by default — the feature is invisible until the user opts in and + /// accepts the disclaimer. + var enabled: Bool = false + var goal: CycleGoal = .understand + /// Hormonal contraception suppresses ovulation, so the thermal analysis is meaningless — + /// period logging stays available but shift detection and predictions are hidden. + var onHormonalContraception: Bool = false + /// Explicit opt-in before any cycle data is included in the AI coach context. Off by + /// default and deliberately separate from the coach's own toggles: menstrual data must + /// never leave the device as a side effect of enabling something else. + var shareWithCoach: Bool = false + /// "Your period is due today — has it started?" local notification with a one-tap log action. + var periodPromptEnabled: Bool = true + /// Optional heads-up two days before the predicted period. + var preAlertEnabled: Bool = false + /// When the user accepted the not-a-medical-device disclaimer; `nil` = never accepted, + /// so the feature cannot be on. + var disclaimerAcceptedAt: Date? + + static let `default` = CycleSettings() + + init() {} + + /// Tolerant decode: missing keys (older stored settings, new fields) fall back to + /// defaults instead of failing the whole decode. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = CycleSettings.default + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? d.enabled + goal = try c.decodeIfPresent(CycleGoal.self, forKey: .goal) ?? d.goal + onHormonalContraception = try c.decodeIfPresent(Bool.self, forKey: .onHormonalContraception) ?? d.onHormonalContraception + shareWithCoach = try c.decodeIfPresent(Bool.self, forKey: .shareWithCoach) ?? d.shareWithCoach + periodPromptEnabled = try c.decodeIfPresent(Bool.self, forKey: .periodPromptEnabled) ?? d.periodPromptEnabled + preAlertEnabled = try c.decodeIfPresent(Bool.self, forKey: .preAlertEnabled) ?? d.preAlertEnabled + disclaimerAcceptedAt = try c.decodeIfPresent(Date.self, forKey: .disclaimerAcceptedAt) + } +} + +/// Observable, UserDefaults-backed store for `CycleSettings`. Mutating `settings` persists +/// immediately. A shared instance keeps Settings, the Vitals card, and notifications in sync. +@MainActor +@Observable +final class CycleSettingsStore { + static let shared = CycleSettingsStore() + + private static let storageKey = "pulseloop.cycle.settings.v1" + private let defaults: UserDefaults + + var settings: CycleSettings { + didSet { persist() } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode(CycleSettings.self, from: data) { + self.settings = decoded + } else { + self.settings = .default + } + } + + /// The feature is only active with both the switch on and the disclaimer accepted. + var isActive: Bool { settings.enabled && settings.disclaimerAcceptedAt != nil } + + private func persist() { + guard let data = try? JSONEncoder().encode(settings) else { return } + defaults.set(data, forKey: Self.storageKey) + } +} diff --git a/PulseLoop/Views/CycleCalendarView.swift b/PulseLoop/Views/CycleCalendarView.swift new file mode 100644 index 00000000..22bcae7d --- /dev/null +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -0,0 +1,193 @@ +import SwiftUI +import SwiftData + +/// Month grid for the cycle detail screen: logged period days filled, predicted period days +/// tinted, the fertile window softly highlighted, ovulation starred, disturbed nights dotted. +/// Tapping any past-or-today day opens the quick log sheet. +struct CycleMonthCalendar: View { + let month: Date // any day inside the displayed month + let overview: CycleOverview + let today: Date + let onSelect: (Date) -> Void + + private var calendar: Calendar { Calendar.current } + + var body: some View { + VStack(spacing: 8) { + weekdayHeader + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 6) { + ForEach(0.. some View { + let facts = overview.loggedDays[CycleDay.key(for: day)] + let isToday = calendar.isDate(day, inSameDayAs: today) + let isFuture = day > today + let analysis = overview.analysis + + let isPredictedPeriod = predictedPeriodDays?.contains(day) ?? false + let inFertileWindow = analysis?.fertileWindow?.contains(day) ?? false + let ovulation = analysis?.ovulation.estimatedDate.map { calendar.isDate($0, inSameDayAs: day) } ?? false + + return Button { + onSelect(day) + } label: { + ZStack(alignment: .topTrailing) { + Circle() + .fill(background(facts: facts, predicted: isPredictedPeriod, fertile: inFertileWindow)) + .overlay(Circle().stroke(isToday ? PulseColors.accent : .clear, lineWidth: 1.5)) + Text("\(calendar.component(.day, from: day))") + .font(.system(size: 13, weight: facts?.isPeriod == true ? .semibold : .regular)) + .foregroundStyle(facts?.isPeriod == true ? Color.white : (isFuture ? PulseColors.textMuted : PulseColors.textPrimary)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + if ovulation { + Image(systemName: analysis?.ovulation.isConfirmed == true ? "star.fill" : "star") + .font(.system(size: 8)) + .foregroundStyle(PulseColors.cycleLuteal) + .offset(x: 1, y: -1) + } + if facts?.isDisturbed == true { + Circle() + .fill(PulseColors.warning) + .frame(width: 5, height: 5) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .offset(y: -2) + } + } + .frame(height: 40) + .opacity(isFuture ? 0.55 : 1) + } + .buttonStyle(.plain) + .disabled(isFuture) + } + + private func background(facts: CycleOverview.CycleDayFacts?, predicted: Bool, fertile: Bool) -> Color { + if facts?.isPeriod == true { return PulseColors.cycle } + if predicted { return PulseColors.cycle.opacity(0.20) } + if fertile { return PulseColors.cycleFertile.opacity(0.12) } + return PulseColors.cardSoft.opacity(0.5) + } + + /// Predicted flow days: the expected start plus a typical 5-day period, future only. + private var predictedPeriodDays: [Date]? { + guard let expected = overview.analysis?.nextPeriod?.expected, expected > today else { return nil } + return (0..<5).compactMap { calendar.date(byAdding: .day, value: $0, to: expected) } + } + + // MARK: - Month math + + private var monthStart: Date { + calendar.date(from: calendar.dateComponents([.year, .month], from: month)) ?? month + } + + private var monthDays: [Date] { + let count = calendar.range(of: .day, in: .month, for: monthStart)?.count ?? 30 + return (0.. Void + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @State private var isPeriod = false + @State private var isDisturbed = false + @State private var notes = "" + @State private var loaded = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text(date.formatted(date: .complete, time: .omitted)) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + + toggleRow("Period", subtitle: "A flow day — day 1 is worked out automatically", isOn: $isPeriod) + toggleRow("Disturbed night", subtitle: "Fever, illness, alcohol… excludes it from the analysis", isOn: $isDisturbed) + + VStack(alignment: .leading, spacing: 6) { + Text("Notes") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(PulseColors.textSecondary) + TextField("Optional", text: $notes, axis: .vertical) + .lineLimit(2...4) + .padding(12) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + PrimaryButton(title: "Save", systemImage: "checkmark") { save() } + } + .padding(24) + .frame(maxHeight: .infinity, alignment: .top) + .background(PulseColors.background) + .presentationDetents([.medium]) + .onAppear { loadIfNeeded() } + } + + private func toggleRow(_ title: String, subtitle: String, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textPrimary) + Text(subtitle).font(.system(size: 11)).foregroundStyle(PulseColors.textMuted) + } + } + .tint(PulseColors.accent) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + private func loadIfNeeded() { + guard !loaded else { return } + loaded = true + guard let existing = CycleRepository.day(for: date, context: modelContext) else { return } + isPeriod = existing.isPeriod + isDisturbed = existing.isDisturbed + notes = existing.notes ?? "" + } + + private func save() { + let day = CycleRepository.dayOrNew(for: date, context: modelContext) + day.isPeriod = isPeriod + day.isDisturbed = isDisturbed + // A manual edit of the toggle overrides any auto-detection provenance. + if !isDisturbed { day.disturbedAutoDetected = false } + let trimmed = notes.trimmingCharacters(in: .whitespacesAndNewlines) + day.notes = trimmed.isEmpty ? nil : trimmed + CycleRepository.save(day, context: modelContext) + CycleNotificationCenter.shared.scheduleNext() + onSaved() + dismiss() + } +} diff --git a/PulseLoop/Views/CycleCard.swift b/PulseLoop/Views/CycleCard.swift new file mode 100644 index 00000000..dc7703ee --- /dev/null +++ b/PulseLoop/Views/CycleCard.swift @@ -0,0 +1,114 @@ +import SwiftUI +import SwiftData + +/// The Vitals-tab cycle card: countdown headline, day + phase, a sparkline of this cycle's +/// nightly temperatures, and — around the predicted date — the one-tap "My period started" +/// button. Tapping the card opens the full detail screen. +struct CycleVitalsCard: View { + @Binding var path: NavigationPath + @Environment(\.modelContext) private var modelContext + @State private var overview: CycleOverview? + @State private var dataChange = PulseDataChange.shared + @State private var settings = CycleSettingsStore.shared + + var body: some View { + // Not one big Button: the "My period started" button lives inside the card, and + // nested buttons fight over taps. The info area navigates; the action button acts. + PulseCard { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + header + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { path.append(AppRoute.cycleDetail) } + + if CycleCopy.shouldOfferPeriodStart(overview?.analysis) { + PeriodStartButton { logPeriodToday() } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .task { reload() } + .onChange(of: dataChange.token) { _, _ in reload() } + } + + private var header: some View { + HStack(spacing: 8) { + Circle().fill(PulseColors.cycle).frame(width: 8, height: 8) + Text("CYCLE") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(PulseColors.textMuted) + Spacer() + if let analysis = overview?.analysis { + Text(settings.settings.onHormonalContraception ? "Tracking" : analysis.phase.label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(PulseColors.textSecondary) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(PulseColors.cardSoft, in: Capsule()) + } + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(PulseColors.textMuted) + } + } + + @ViewBuilder private var content: some View { + if let analysis = overview?.analysis { + Text(CycleCopy.headline(analysis, hormonal: settings.settings.onHormonalContraception)) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(CycleCopy.subtitle(analysis, hormonal: settings.settings.onHormonalContraception)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(PulseColors.textMuted) + + let temps = (overview?.chartDays ?? []).filter { !$0.excluded }.compactMap(\.temperature) + if temps.count > 1 { + MiniSparkline(values: temps, color: PulseColors.cycle) + .frame(height: 30) + } + } else { + Text("Set up cycle tracking") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + Text("Log your first period day — temperatures are already being collected while you sleep.") + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textMuted) + } + } + + private func reload() { + overview = CycleService.overview(context: modelContext) + } + + private func logPeriodToday() { + let day = CycleRepository.dayOrNew(for: Date(), context: modelContext) + day.isPeriod = true + CycleRepository.save(day, context: modelContext) + CycleNotificationCenter.shared.scheduleNext() + reload() + } +} + +/// The one-tap happy path. Kept as its own small view so the card and the detail screen +/// render it identically. +struct PeriodStartButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("My period started", systemImage: "drop.fill") + .font(.system(size: 14, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 40) + .foregroundStyle(PulseColors.textPrimary) + .background(PulseColors.cycle.opacity(0.18)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(PulseColors.cycle.opacity(0.45), lineWidth: 1)) + } + .buttonStyle(.plain) + } +} diff --git a/PulseLoop/Views/CycleDetailView.swift b/PulseLoop/Views/CycleDetailView.swift new file mode 100644 index 00000000..06426f18 --- /dev/null +++ b/PulseLoop/Views/CycleDetailView.swift @@ -0,0 +1,425 @@ +import SwiftUI +import SwiftData + +/// Full cycle screen behind the Vitals card: phase ring + countdown, BBT chart with the +/// coverline (browsable across past cycles), the month calendar for retro-logging, and the +/// cycle-length history. Read-only except the log sheet and the one-tap period button. +struct CycleDetailView: View { + @Binding var path: NavigationPath + @Environment(\.modelContext) private var modelContext + @Query private var profiles: [UserProfile] + @State private var overview: CycleOverview? + @State private var settings = CycleSettingsStore.shared + @State private var dataChange = PulseDataChange.shared + @State private var displayedMonth = Date() + @State private var logItem: CycleLogItem? + /// 0 = current cycle; 1…n = completed cycles counting back from the most recent. + @State private var cycleOffset = 0 + @State private var pastChartDays: [CycleChartDay] = [] + @State private var dismissedSuggestion: Date? + + private var units: UnitsPreference { profiles.first?.units ?? .metric } + private var hormonal: Bool { settings.settings.onHormonalContraception } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let overview { + content(overview) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 40) + } + .background(PulseColors.background) + .navigationTitle("Cycle") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { path.append(AppRoute.settingsCycle) } label: { + Image(systemName: "slider.horizontal.3") + } + } + } + .task { reload() } + .onChange(of: dataChange.token) { _, _ in reload() } + .sheet(item: $logItem) { item in + CycleLogSheet(date: item.date) { reload() } + } + } + + @ViewBuilder + private func content(_ overview: CycleOverview) -> some View { + if let analysis = overview.analysis { + if let suggestion = analysis.disturbanceSuggestion, suggestion != dismissedSuggestion { + disturbanceBanner(suggestion) + } + ForEach(bannersToShow(analysis), id: \.self) { message in + StatusCopy(title: "Heads-up", body: message) + } + statusCard(analysis) + if CycleCopy.shouldOfferPeriodStart(analysis) { + PeriodStartButton { logPeriod(on: Date()) } + } + if !hormonal { + chartCard(analysis) + } + calendarCard(overview) + if !analysis.completedCycles.isEmpty { + historyCard(analysis) + } + } else { + emptyStateCard + calendarCard(overview) + } + footer + } + + // MARK: - Status + + private func statusCard(_ analysis: CycleAnalysis) -> some View { + PulseCard { + VStack(spacing: 14) { + CyclePhaseRing(segments: ringSegments(analysis), progress: ringProgress(analysis)) { + VStack(spacing: 2) { + Text("Day \(analysis.dayNumber)") + .font(.system(size: 30, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + Text(hormonal ? "Tracking" : analysis.phase.label) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(PulseColors.textSecondary) + } + } + .frame(height: 190) + + Text(CycleCopy.headline(analysis, hormonal: hormonal)) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + + if let prediction = analysis.nextPeriod, !hormonal { + Text(predictionLine(prediction)) + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textMuted) + } else if analysis.completedCycles.isEmpty && !hormonal { + Text("First cycle — predictions unlock once one full cycle is logged.") + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textMuted) + } + legend + } + .frame(maxWidth: .infinity) + } + } + + private func predictionLine(_ prediction: PeriodPrediction) -> String { + let expected = prediction.expected.formatted(.dateTime.day().month(.wide)) + let earliest = prediction.earliest.formatted(.dateTime.day()) + let latest = prediction.latest.formatted(.dateTime.day().month(.abbreviated)) + return "Next period around \(expected) (\(earliest)–\(latest))" + } + + private var legend: some View { + HStack(spacing: 14) { + legendDot(PulseColors.cycle, "Period") + if !hormonal { + legendDot(PulseColors.cycleFertile, "Fertile") + legendDot(PulseColors.cycleLuteal, "Luteal") + } + } + .font(.system(size: 10)) + .foregroundStyle(PulseColors.textMuted) + } + + private func legendDot(_ color: Color, _ label: String) -> some View { + HStack(spacing: 4) { + Circle().fill(color).frame(width: 6, height: 6) + Text(label) + } + } + + // MARK: - Chart + + @ViewBuilder + private func chartCard(_ analysis: CycleAnalysis) -> some View { + let past = analysis.completedCycles + PulseCard { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Basal temperature") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer() + if !past.isEmpty { + cycleSelector(pastCount: past.count) + } + } + if cycleOffset == 0 { + CycleBBTChart( + days: overview?.chartDays ?? [], + coverline: analysis.coverline, + fertileWindow: analysis.fertileWindow, + units: units + ) + } else { + CycleBBTChart(days: pastChartDays, coverline: nil, fertileWindow: nil, units: units) + } + Text(cycleOffset == 0 + ? "Nightly medians of your ring's sleep temperature. Hollow points are excluded nights." + : pastCycleCaption(past)) + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + } + } + } + + private func cycleSelector(pastCount: Int) -> some View { + HStack(spacing: 10) { + Button { + setCycleOffset(min(cycleOffset + 1, pastCount)) + } label: { + Image(systemName: "chevron.left").font(.system(size: 12, weight: .semibold)) + } + .disabled(cycleOffset >= pastCount) + Text(cycleOffset == 0 ? "Current" : "−\(cycleOffset)") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(PulseColors.textSecondary) + .frame(minWidth: 52) + Button { + setCycleOffset(max(cycleOffset - 1, 0)) + } label: { + Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)) + } + .disabled(cycleOffset == 0) + } + .foregroundStyle(PulseColors.textSecondary) + } + + private func pastCycleCaption(_ past: [CompletedCycleSummary]) -> String { + guard cycleOffset >= 1, cycleOffset <= past.count else { return "" } + let cycle = past[past.count - cycleOffset] + var caption = "Started \(cycle.start.formatted(.dateTime.day().month(.abbreviated))) · \(cycle.lengthDays) days" + if let index = cycle.ovulationDayIndex { + caption += " · est. ovulation day \(index + 1)" + } + return caption + } + + private func setCycleOffset(_ offset: Int) { + cycleOffset = offset + guard offset >= 1, let past = overview?.analysis?.completedCycles, offset <= past.count else { return } + pastChartDays = CycleService.chartDays(for: past[past.count - offset], context: modelContext) + } + + // MARK: - Calendar + + private func calendarCard(_ overview: CycleOverview) -> some View { + PulseCard { + VStack(spacing: 10) { + HStack { + Button { + displayedMonth = Calendar.current.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth + } label: { + Image(systemName: "chevron.left").font(.system(size: 13, weight: .semibold)) + } + Spacer() + Text(displayedMonth.formatted(.dateTime.month(.wide).year())) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer() + Button { + displayedMonth = Calendar.current.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth + } label: { + Image(systemName: "chevron.right").font(.system(size: 13, weight: .semibold)) + } + } + .foregroundStyle(PulseColors.textSecondary) + + CycleMonthCalendar(month: displayedMonth, overview: overview, today: Date()) { day in + logItem = CycleLogItem(date: day) + } + Text("Tap a day to log a period or exclude a disturbed night.") + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + } + } + } + + // MARK: - History + + private func historyCard(_ analysis: CycleAnalysis) -> some View { + PulseCard { + VStack(alignment: .leading, spacing: 10) { + Text("Past cycles") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + ForEach(analysis.completedCycles.suffix(6).reversed()) { cycle in + HStack { + Text(cycle.start.formatted(.dateTime.day().month(.abbreviated).year(.twoDigits))) + .font(.system(size: 13)) + .foregroundStyle(PulseColors.textSecondary) + Spacer() + if let luteal = cycle.lutealDays { + Text("luteal \(luteal) d") + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + } + Text("\(cycle.lengthDays) days") + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + } + } + if let typical = analysis.typicalCycleLengthDays { + Text("Typical length \(typical) days · luteal phase \(analysis.lutealLengthDays) days") + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Banners & empty state + + private func disturbanceBanner(_ night: Date) -> some View { + PulseCard { + VStack(alignment: .leading, spacing: 10) { + Label("Last night looks unusual", systemImage: "thermometer.variable.and.figure") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.warning) + Text("The night of \(night.formatted(.dateTime.day().month(.wide))) ran well above your recent " + + "baseline — fever, alcohol, or a bad night can do that. Exclude it from the analysis?") + .font(.system(size: 13)) + .foregroundStyle(PulseColors.textSecondary) + HStack(spacing: 8) { + QuickActionButton(label: "Exclude night", accent: true) { excludeNight(night) } + QuickActionButton(label: "Keep it") { dismissedSuggestion = night } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var emptyStateCard: some View { + PulseCard { + VStack(spacing: 12) { + Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") + .font(.system(size: 34)) + .foregroundStyle(PulseColors.cycle) + Text("Start with day 1") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + Text("Log the first day of your period — that's the only input needed. Overnight temperatures " + + "from your ring do the rest. You can backfill past days from the calendar below.") + .font(.system(size: 13)) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + PeriodStartButton { logPeriod(on: Date()) } + } + .frame(maxWidth: .infinity) + } + } + + private var footer: some View { + Text("Estimates only — not clinically validated, not a medical device, and never a contraception method.") + .font(.system(size: 10)) + .foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.top, 4) + } + + private func bannersToShow(_ analysis: CycleAnalysis) -> [String] { + var messages: [String] = [] + if hormonal { + messages.append("Hormonal contraception suppresses ovulation, so temperature analysis and fertility " + + "estimates are paused. Period logging and the calendar still work.") + } + if settings.settings.goal == .avoid && !hormonal { + messages.append("Reminder: the fertile window shown is deliberately widened, but PulseLoop is not a contraception method.") + } + messages.append(contentsOf: analysis.flags.map(CycleCopy.flagMessage)) + return messages + } + + // MARK: - Ring math + + /// Arc segments for the phase ring, over a full turn representing the expected cycle. + private func ringSegments(_ analysis: CycleAnalysis) -> [CyclePhaseSegment] { + let calendar = Calendar.current + let total = Double(ringTotalDays(analysis)) + var segments: [CyclePhaseSegment] = [] + + // Leading run of logged period days (at least the derived day 1). + var periodLength = 0 + for day in overview?.chartDays ?? [] { + if day.isPeriod { periodLength += 1 } else { break } + } + periodLength = max(periodLength, 1) + segments.append(CyclePhaseSegment(start: 0, end: Double(periodLength) / total, color: PulseColors.cycle)) + + guard !hormonal else { return segments } + + if let window = analysis.fertileWindow { + let startIndex = max(0, CycleAnalyzer.daysBetween(analysis.cycleStart, window.lowerBound, calendar: calendar)) + let endIndex = CycleAnalyzer.daysBetween(analysis.cycleStart, window.upperBound, calendar: calendar) + 1 + if endIndex > startIndex { + segments.append(CyclePhaseSegment( + start: min(Double(startIndex) / total, 1), + end: min(Double(endIndex) / total, 1), + color: PulseColors.cycleFertile.opacity(0.85) + )) + } + if analysis.ovulation.isConfirmed { + segments.append(CyclePhaseSegment( + start: min(Double(endIndex) / total, 1), + end: 1, + color: PulseColors.cycleLuteal.opacity(0.7) + )) + } + } + return segments + } + + private func ringProgress(_ analysis: CycleAnalysis) -> Double { + let total = Double(ringTotalDays(analysis)) + return min(Double(analysis.dayNumber - 1) / total, 0.999) + } + + private func ringTotalDays(_ analysis: CycleAnalysis) -> Int { + var total = analysis.typicalCycleLengthDays ?? 28 + if let prediction = analysis.nextPeriod { + total = max(total, CycleAnalyzer.daysBetween(analysis.cycleStart, prediction.expected)) + } + return max(total, analysis.dayNumber) + } + + // MARK: - Actions + + private func reload() { + overview = CycleService.overview(context: modelContext) + if let past = overview?.analysis?.completedCycles, cycleOffset > past.count { + cycleOffset = 0 + } + } + + private func logPeriod(on date: Date) { + let day = CycleRepository.dayOrNew(for: date, context: modelContext) + day.isPeriod = true + CycleRepository.save(day, context: modelContext) + CycleNotificationCenter.shared.scheduleNext() + reload() + } + + private func excludeNight(_ night: Date) { + let day = CycleRepository.dayOrNew(for: night, context: modelContext) + day.isDisturbed = true + day.disturbedAutoDetected = true + CycleRepository.save(day, context: modelContext) + reload() + } +} + +private struct CycleLogItem: Identifiable { + let date: Date + var id: Date { date } +} diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index a7822d82..a25d8712 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -172,6 +172,10 @@ struct RootAppView: View { StravaSettingsView() case .settingsPrivacyData: PrivacyDataSettingsView() + case .settingsCycle: + CycleSettingsView() + case .cycleDetail: + CycleDetailView(path: $path) case .settingsAbout: AboutSettingsView(path: $path) case .settingsNutrition: diff --git a/PulseLoop/Views/Settings/CycleSettingsView.swift b/PulseLoop/Views/Settings/CycleSettingsView.swift new file mode 100644 index 00000000..f695ee58 --- /dev/null +++ b/PulseLoop/Views/Settings/CycleSettingsView.swift @@ -0,0 +1,270 @@ +import SwiftUI +import SwiftData +import UserNotifications + +/// Cycle-tracking settings: master switch (behind an explicit disclaimer), tracking goal, +/// hormonal-contraception flag, notifications, and the coach-sharing opt-in. Only reachable +/// when the paired ring measures temperature (`CycleService.isAvailable`). +struct CycleSettingsView: View { + @Environment(\.modelContext) private var modelContext + @Environment(RingSyncCoordinator.self) private var coordinator + @State private var store = CycleSettingsStore.shared + @State private var showDisclaimer = false + @State private var notifPermissionDenied = false + + var body: some View { + ScrollView { + VStack(spacing: 16) { + SectionHeader(title: "Cycle tracking", action: nil) + toggleRow("Track my cycle", isOn: Binding( + get: { store.isActive }, + set: { setEnabled($0) } + )) + caption("Estimates cycle phases from the skin temperature your ring already measures during sleep. " + + "You only log period days — everything else is passive.") + + if store.isActive { + goalSection + notificationsSection + privacySection + } + + SectionHeader(title: "About these estimates", action: nil) + StatusCopy(title: "Not a medical device", body: Self.disclaimerText) + } + .padding() + } + .background(PulseColors.background) + .navigationTitle("Cycle") + .sheet(isPresented: $showDisclaimer) { + CycleDisclaimerSheet( + onAccept: { goal, hormonal in + store.settings.goal = goal + store.settings.onHormonalContraception = hormonal + store.settings.disclaimerAcceptedAt = Date() + store.settings.enabled = true + ensureRingMeasuresTemperature() + requestNotificationPermission() + }, + onCancel: { showDisclaimer = false } + ) + } + } + + // MARK: - Sections + + @ViewBuilder private var goalSection: some View { + SectionHeader(title: "Goal", action: nil) + labeledRow("I'm tracking to") { + Picker("Goal", selection: Binding( + get: { store.settings.goal }, + set: { store.settings.goal = $0 } + )) { + ForEach(CycleGoal.allCases) { goal in + Text(goal.label).tag(goal) + } + } + .pickerStyle(.menu) + .tint(PulseColors.accent) + } + if store.settings.goal == .avoid { + Text("Heads-up: PulseLoop shows a deliberately wider fertile window in this mode, " + + "but it is NOT a contraception method and must never be relied on as one.") + .font(.caption).foregroundStyle(PulseColors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + + toggleRow("I use hormonal contraception", isOn: Binding( + get: { store.settings.onHormonalContraception }, + set: { store.settings.onHormonalContraception = $0 } + )) + if store.settings.onHormonalContraception { + caption("Hormonal contraception suppresses ovulation, so temperature analysis and fertility " + + "estimates are hidden — period logging and the calendar stay available.") + } + } + + @ViewBuilder private var notificationsSection: some View { + SectionHeader(title: "Notifications", action: nil) + toggleRow("Ask on the predicted day", isOn: Binding( + get: { store.settings.periodPromptEnabled }, + set: { store.settings.periodPromptEnabled = $0; CycleNotificationCenter.shared.scheduleNext() } + )) + caption("“Your period is due around today — has it started?” with a one-tap Yes that logs day 1 from the lock screen.") + toggleRow("Heads-up 2 days before", isOn: Binding( + get: { store.settings.preAlertEnabled }, + set: { store.settings.preAlertEnabled = $0; CycleNotificationCenter.shared.scheduleNext() } + )) + if notifPermissionDenied { + Text("Notifications are disabled for PulseLoop in iOS Settings.") + .font(.caption).foregroundStyle(PulseColors.danger) + } + } + + @ViewBuilder private var privacySection: some View { + SectionHeader(title: "Privacy", action: nil) + toggleRow("Share cycle data with the AI Coach", isOn: Binding( + get: { store.settings.shareWithCoach }, + set: { store.settings.shareWithCoach = $0 } + )) + caption("Off by default. Cycle data stays on this device and is never included in coach requests " + + "or diagnostics exports unless you turn this on.") + } + + // MARK: - Actions + + private func setEnabled(_ on: Bool) { + if on { + // First enable (or re-enable) goes through the disclaimer, always. + showDisclaimer = true + } else { + store.settings.enabled = false + CycleNotificationCenter.shared.cancel() + } + } + + /// Cycle tracking is pointless with the ring's temperature sampling off — flip it on in + /// the device config and push it, mirroring MeasurementSettingsView's save path. + private func ensureRingMeasuresTemperature() { + guard let device = DeviceRepository.current(context: modelContext) else { return } + let config = MeasurementConfigRepository.configOrDefault(deviceId: device.id, context: modelContext) + guard !config.temperatureEnabled else { return } + config.temperatureEnabled = true + MeasurementConfigRepository.save(config, context: modelContext) + coordinator.applyMeasurementSettings() + } + + private func requestNotificationPermission() { + guard store.settings.periodPromptEnabled || store.settings.preAlertEnabled else { return } + Task { + let granted = (try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge])) ?? false + notifPermissionDenied = !granted + if granted { CycleNotificationCenter.shared.scheduleNext() } + } + } + + // MARK: - Layout helpers (match settings idiom) + + private func caption(_ text: String) -> some View { + Text(text) + .font(.caption).foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + + private func labeledRow(_ title: String, @ViewBuilder content: () -> Content) -> some View { + HStack { + Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textPrimary) + Spacer() + content() + } + .padding(.horizontal, 16).padding(.vertical, 10) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + private func toggleRow(_ title: String, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textPrimary) + } + .tint(PulseColors.accent) + .padding(.horizontal, 16).padding(.vertical, 6) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + + static let disclaimerText = """ + PulseLoop estimates your cycle phases from the skin temperature measured by your ring. \ + These estimates are indicators, not certainties: their reliability depends on your sleep, \ + how the ring is worn, and your physiology. This feature is not a medical device, is not \ + clinically validated, and must never be used on its own as a contraception method or to \ + make medical decisions. Talk to a healthcare professional for any medical question. \ + PulseLoop's contributors accept no liability for how these estimates are used. + """ +} + +/// The activation flow: disclaimer with explicit acceptance, tracking-goal choice, and the +/// hormonal-contraception question — asked once, editable later in settings. +struct CycleDisclaimerSheet: View { + let onAccept: (CycleGoal, Bool) -> Void + let onCancel: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var goal: CycleGoal = .understand + @State private var hormonalContraception = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Before you start") + .font(.system(size: 26, weight: .semibold, design: .rounded)) + .foregroundStyle(PulseColors.textPrimary) + + Text(CycleSettingsView.disclaimerText) + .font(.system(size: 14)) + .foregroundStyle(PulseColors.textSecondary) + .lineSpacing(4) + + VStack(alignment: .leading, spacing: 10) { + Text("I'm tracking to…") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + ForEach(CycleGoal.allCases) { option in + goalOption(option) + } + } + + if goal == .avoid { + Text("PulseLoop will show a deliberately wider fertile window, " + + "but it is NOT a contraception method. Never rely on it as one.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(PulseColors.warning) + } + + Toggle(isOn: $hormonalContraception) { + Text("I currently use hormonal contraception") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(PulseColors.textPrimary) + } + .tint(PulseColors.accent) + + PrimaryButton(title: "I understand — enable", systemImage: "checkmark") { + onAccept(goal, hormonalContraception) + dismiss() + } + SecondaryButton(title: "Cancel", systemImage: "xmark") { + onCancel() + dismiss() + } + } + .padding(24) + } + .background(PulseColors.background) + .presentationDetents([.large]) + } + + private func goalOption(_ option: CycleGoal) -> some View { + Button { + goal = option + } label: { + HStack { + Image(systemName: goal == option ? "largecircle.fill.circle" : "circle") + .foregroundStyle(goal == option ? PulseColors.accent : PulseColors.textMuted) + Text(option.label) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(PulseColors.textPrimary) + Spacer() + } + .padding(.horizontal, 14).padding(.vertical, 12) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(goal == option ? PulseColors.accent.opacity(0.6) : PulseColors.borderSubtle, lineWidth: 1)) + } + .buttonStyle(.plain) + } +} diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index fca2041f..78a524db 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -58,7 +58,7 @@ struct SettingsView: View { // blends consistently (iOS-Settings inset-grouped look). VStack(spacing: 20) { SettingsSection(title: "General", rows: generalRows(caps)) - SettingsSection(title: "Metrics", rows: metricsRows) + SettingsSection(title: "Metrics", rows: metricsRows(caps)) SettingsSection(title: "Resources", rows: resourcesRows(caps)) } .pulseGlassContainer(spacing: 20) @@ -104,8 +104,8 @@ struct SettingsView: View { return rows } - private var metricsRows: [SettingsRowItem] { - [ + private func metricsRows(_ caps: Set) -> [SettingsRowItem] { + var rows: [SettingsRowItem] = [ SettingsRowItem(icon: "circle.circle", tint: PulseColors.accent, title: "Today") { path.append(AppRoute.settingsToday) }, @@ -130,6 +130,17 @@ struct SettingsView: View { path.append(AppRoute.settingsNutrition) } ] + // Cycle tracking rides on passive overnight temperature, which only rings declaring + // `.temperature` (Colmi) capture — jring never shows this. Off by default, like Nutrition. + if caps.contains(.temperature) { + rows.append(SettingsRowItem( + icon: "arrow.trianglehead.2.clockwise.rotate.90", tint: PulseColors.cycle, title: "Cycle Tracking", + trailingValue: CycleSettingsStore.shared.isActive ? "On" : "Off" + ) { + path.append(AppRoute.settingsCycle) + }) + } + return rows } private func resourcesRows(_ caps: Set) -> [SettingsRowItem] { diff --git a/PulseLoop/Views/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index 9f6cfbc8..04d1f0b9 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -13,6 +13,7 @@ struct VitalsView: View { @Query private var profiles: [UserProfile] @State private var measuring: MeasurementSheet.Kind? @State private var dataChange = PulseDataChange.shared + @State private var cycleSettings = CycleSettingsStore.shared /// Owns the prepared vitals state. Created lazily in `.task` (never in `body`) so a `body` /// re-render never triggers DB work — it just reads the already-prepared store. @State private var store: VitalsStore? @@ -141,6 +142,10 @@ struct VitalsView: View { symbolName: { $0.reorderSymbolName } ) } + // Cycle tracking (opt-in, rides on the Colmi's passive overnight temperature). + if cycleSettings.isActive && store.capabilities.contains(.temperature) { + CycleVitalsCard(path: $path) + } } } diff --git a/PulseLoopTests/CycleAnalyzerTests.swift b/PulseLoopTests/CycleAnalyzerTests.swift new file mode 100644 index 00000000..c3775087 --- /dev/null +++ b/PulseLoopTests/CycleAnalyzerTests.swift @@ -0,0 +1,271 @@ +import XCTest +@testable import PulseLoop + +/// Pure-algorithm tests for the cycle analyzer: cycle-start derivation, the 3-over-6 rule +/// with both Sensiplan exceptions, disturbed-day handling, predictions, and the status copy. +/// Everything runs on synthetic `CycleDayRecord` arrays — no store, no clock. +@MainActor +final class CycleAnalyzerTests: XCTestCase { + private let calendar = Calendar.current + private lazy var base = calendar.startOfDay(for: Date()) + + private func day(_ offset: Int) -> Date { + calendar.date(byAdding: .day, value: offset, to: base)! + } + + /// Records for cycle day 1…n (day 1 == offset 0): `temps[i]` maps to day i+1. + private func records( + temps: [Double?], + periodDays: Set = [1, 2, 3, 4], + disturbed: Set = [] + ) -> [CycleDayRecord] { + temps.enumerated().map { index, temp in + let dayNumber = index + 1 + return CycleDayRecord( + date: day(index), + temperature: temp, + isPeriod: periodDays.contains(dayNumber), + isDisturbed: disturbed.contains(dayNumber) + ) + } + } + + /// A textbook biphasic cycle: 10 low nights then a sustained rise. + private func biphasicTemps(lowDays: Int = 10, highDays: Int = 8, low: Double = 36.0, high: Double = 36.5) -> [Double?] { + Array(repeating: low, count: lowDays) + Array(repeating: high, count: highDays) + } + + // MARK: - Cycle starts + + func testPeriodStartDerivationSplitsOnGap() { + var days: [CycleDayRecord] = [] + // Period days 0–4, a spotting gap (day 6 flagged), then a fresh period 28–31. + for offset in [0, 1, 2, 3, 4, 6, 28, 29, 30, 31] { + days.append(CycleDayRecord(date: day(offset), temperature: nil, isPeriod: true, isDisturbed: false)) + } + let starts = CycleAnalyzer.periodStarts(days: days, calendar: calendar) + // Day 6 is ≤3 days after day 4 → same period; day 28 opens a new cycle. + XCTAssertEqual(starts, [day(0), day(28)]) + } + + func testNoPeriodLoggedReturnsNil() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: [36.0, 36.1], periodDays: []), + goal: .understand, today: day(1), calendar: calendar + ) + XCTAssertNil(analysis) + } + + // MARK: - Thermal shift detection + + func testIdealCycleConfirmsOvulation() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps()), + goal: .understand, today: day(17), calendar: calendar + )! + guard case let .confirmed(estimated, confirmedOn) = analysis.ovulation else { + return XCTFail("expected confirmed, got \(analysis.ovulation)") + } + // Raw highs start day 11; the 3-day rolling median delays the smoothed rise to day 12, + // so the estimate lands on day 11 and confirmation on the 3rd smoothed high (day 14). + XCTAssertEqual(estimated, day(10)) + XCTAssertEqual(confirmedOn, day(13)) + XCTAssertEqual(analysis.coverline, 36.0) + XCTAssertEqual(analysis.phase, .luteal) + } + + func testFlatCycleDetectsNothing() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: Array(repeating: 36.0, count: 20)), + goal: .understand, today: day(19), calendar: calendar + )! + XCTAssertEqual(analysis.ovulation, .notDetected) + XCTAssertNil(analysis.coverline) + } + + func testTwoRawHighDaysReadAsProbable() { + // 10 lows + 3 raw highs → 2 smoothed highs: rise underway but unconfirmed. + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps(highDays: 3)), + goal: .understand, today: day(12), calendar: calendar + )! + guard case .probable = analysis.ovulation else { + return XCTFail("expected probable, got \(analysis.ovulation)") + } + XCTAssertEqual(analysis.phase, .fertile) + } + + func testDisturbedDaysAreSkippedNotFatal() { + // Fever spike on day 8 is flagged disturbed → the analysis must still confirm. + var temps = biphasicTemps() + temps[7] = 37.2 + let analysis = CycleAnalyzer.analyze( + days: records(temps: temps, disturbed: [8]), + goal: .understand, today: day(17), calendar: calendar + )! + XCTAssertTrue(analysis.ovulation.isConfirmed) + XCTAssertEqual(analysis.coverline, 36.0) + } + + func testUnflaggedFeverSuggestsDisturbance() { + var temps: [Double?] = Array(repeating: 36.0, count: 9) + temps.append(36.8) // last night, way above baseline, not excluded + let analysis = CycleAnalyzer.analyze( + days: records(temps: temps), + goal: .understand, today: day(9), calendar: calendar + )! + XCTAssertEqual(analysis.disturbanceSuggestion, day(9)) + } + + func testConfirmedLutealHighsAreNotFlaggedAsFever() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps()), + goal: .understand, today: day(17), calendar: calendar + )! + XCTAssertTrue(analysis.ovulation.isConfirmed) + XCTAssertNil(analysis.disturbanceSuggestion) + } + + // MARK: - Rise-rule exceptions (tested on the rule directly, past the smoothing) + + private let config = CycleAnalyzerConfig() + + func testRiseRulePlainConfirmation() { + // 6 reference lows at 36.0, then 3 highs with the 3rd clearing coverline + delta. + let values: [Double] = [36.0, 36.0, 36.0, 36.0, 36.0, 36.0, 36.1, 36.2, 36.3] + XCTAssertEqual( + CycleAnalyzer.evaluateRise(candidate: 6, coverline: 36.0, values: values, config: config), + .confirmed(finalIndex: 8) + ) + } + + func testException1WeakThirdRescuedByFourth() { + // 3rd high is above the line but < delta → a 4th above the line confirms. + let values: [Double] = [36.0, 36.0, 36.0, 36.0, 36.0, 36.0, 36.1, 36.2, 36.1, 36.1] + XCTAssertEqual( + CycleAnalyzer.evaluateRise(candidate: 6, coverline: 36.0, values: values, config: config), + .confirmed(finalIndex: 9) + ) + } + + func testException2DipRescuedByFullDeltaCloser() { + // 2nd value dips to the line → forgiven; the replacement must clear the full delta. + let values: [Double] = [36.0, 36.0, 36.0, 36.0, 36.0, 36.0, 36.1, 35.95, 36.2, 36.3] + XCTAssertEqual( + CycleAnalyzer.evaluateRise(candidate: 6, coverline: 36.0, values: values, config: config), + .confirmed(finalIndex: 9) + ) + } + + func testException2CloserBelowDeltaFails() { + // After a dip the exceptions must not combine: a weak closer fails the candidate. + let values: [Double] = [36.0, 36.0, 36.0, 36.0, 36.0, 36.0, 36.1, 35.95, 36.2, 36.1, 36.1] + XCTAssertEqual( + CycleAnalyzer.evaluateRise(candidate: 6, coverline: 36.0, values: values, config: config), + .failed + ) + } + + func testTwoDipsFailTheCandidate() { + let values: [Double] = [36.0, 36.0, 36.0, 36.0, 36.0, 36.0, 36.1, 35.9, 36.1, 35.9] + XCTAssertEqual( + CycleAnalyzer.evaluateRise(candidate: 6, coverline: 36.0, values: values, config: config), + .failed + ) + } + + // MARK: - Predictions + + func testFirstCycleWithoutShiftHasNoPrediction() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: Array(repeating: 36.0, count: 12)), + goal: .understand, today: day(11), calendar: calendar + )! + XCTAssertNil(analysis.nextPeriod) + XCTAssertTrue(analysis.completedCycles.isEmpty) + } + + func testConfirmedOvulationPredictsPeriodFromLutealLength() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps()), + goal: .understand, today: day(17), calendar: calendar + )! + // No history → default 14-day luteal from the estimated ovulation (day 11). + XCTAssertEqual(analysis.nextPeriod?.expected, day(10 + 14)) + } + + func testCompletedCyclesDriveTypicalLengthPrediction() { + // Two completed 28-day cycles (period at days 0 and 28), current start at 56, no temps. + var days: [CycleDayRecord] = [] + for offset in [0, 28, 56] { + days.append(CycleDayRecord(date: day(offset), temperature: nil, isPeriod: true, isDisturbed: false)) + } + let analysis = CycleAnalyzer.analyze(days: days, goal: .understand, today: day(60), calendar: calendar)! + XCTAssertEqual(analysis.typicalCycleLengthDays, 28) + XCTAssertEqual(analysis.nextPeriod?.expected, day(56 + 28)) + XCTAssertEqual(analysis.completedCycles.map(\.lengthDays), [28, 28]) + } + + func testAvoidGoalWidensTheFertileWindow() { + var days: [CycleDayRecord] = [] + for offset in [0, 28] { + days.append(CycleDayRecord(date: day(offset), temperature: nil, isPeriod: true, isDisturbed: false)) + } + let today = day(34) + let standard = CycleAnalyzer.analyze(days: days, goal: .understand, today: today, calendar: calendar)! + let cautious = CycleAnalyzer.analyze(days: days, goal: .avoid, today: today, calendar: calendar)! + let standardWindow = standard.fertileWindow! + let cautiousWindow = cautious.fertileWindow! + XCTAssertTrue(cautiousWindow.lowerBound < standardWindow.lowerBound) + XCTAssertTrue(cautiousWindow.upperBound > standardWindow.upperBound) + } + + // MARK: - Flags + + func testLongHighPlateauFlagsPossiblePregnancy() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps(highDays: 20)), + goal: .understand, today: day(29), calendar: calendar + )! + XCTAssertTrue(analysis.flags.contains(.possiblePregnancy)) + } + + func testLateCycleWithoutShiftFlagsHonestly() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: Array(repeating: 36.0, count: 36)), + goal: .understand, today: day(35), calendar: calendar + )! + XCTAssertTrue(analysis.flags.contains(.noThermalShiftYet)) + } + + // MARK: - Copy + + func testHeadlinePrioritizesPeriodOverEverything() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: [36.0, 36.0], periodDays: [1, 2]), + goal: .understand, today: day(1), calendar: calendar + )! + XCTAssertEqual(CycleCopy.headline(analysis, hormonal: false, today: day(1), calendar: calendar), "Period — day 2") + } + + func testHeadlineCountsDownToPredictedPeriod() { + var days: [CycleDayRecord] = [] + for offset in [0, 28] { + days.append(CycleDayRecord(date: day(offset), temperature: nil, isPeriod: true, isDisturbed: false)) + } + let analysis = CycleAnalyzer.analyze(days: days, goal: .understand, today: day(52), calendar: calendar)! + XCTAssertEqual(CycleCopy.headline(analysis, hormonal: false, today: day(52), calendar: calendar), "Period in ~4 days") + } + + func testPeriodStartButtonOnlyNearPredictedDate() { + var days: [CycleDayRecord] = [] + for offset in [0, 28] { + days.append(CycleDayRecord(date: day(offset), temperature: nil, isPeriod: true, isDisturbed: false)) + } + let midCycle = CycleAnalyzer.analyze(days: days, goal: .understand, today: day(40), calendar: calendar)! + XCTAssertFalse(CycleCopy.shouldOfferPeriodStart(midCycle, today: day(40), calendar: calendar)) + let nearDue = CycleAnalyzer.analyze(days: days, goal: .understand, today: day(54), calendar: calendar)! + XCTAssertTrue(CycleCopy.shouldOfferPeriodStart(nearDue, today: day(54), calendar: calendar)) + XCTAssertTrue(CycleCopy.shouldOfferPeriodStart(nil, today: day(0), calendar: calendar)) + } +} diff --git a/PulseLoopTests/CycleBBTServiceTests.swift b/PulseLoopTests/CycleBBTServiceTests.swift new file mode 100644 index 00000000..61bc1a9b --- /dev/null +++ b/PulseLoopTests/CycleBBTServiceTests.swift @@ -0,0 +1,142 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Nightly-BBT extraction + cycle service plumbing on in-memory SwiftData: sleep-session +/// windowing, awake-block exclusion, the stable-window fallback, and capability gating. +@MainActor +final class CycleBBTServiceTests: XCTestCase { + private var context: ModelContext! + private let calendar = Calendar.current + + override func setUp() async throws { + context = try TestSupport.makeContext() + CycleSettingsStore.shared.settings = .default + } + + private func insertTemp(_ value: Double, at date: Date) { + TestSupport.insertMeasurement(kind: .temperature, value: value, timestamp: date, into: context) + } + + // MARK: - Session path + + func testNightMedianExcludesAwakeSamples() throws { + // A 7h night starting 23:00: 2h light, 1h awake, 4h light. + let nightStart = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1))! + let stages: [SleepStage] = Array(repeating: .light, count: 120) + + Array(repeating: .awake, count: 60) + + Array(repeating: .light, count: 240) + let session = TestSupport.insertSleep(nightStart: nightStart, stages: stages, into: context) + + // Ring cadence: a sample every 30 min. The two samples inside the awake hour are hot + // outliers that must not touch the median. + for halfHour in 0..<14 { + let ts = calendar.date(byAdding: .minute, value: 15 + halfHour * 30, to: nightStart)! + let minutesIn = calendar.dateComponents([.minute], from: nightStart, to: ts).minute! + let isAwake = minutesIn >= 120 && minutesIn < 180 + insertTemp(isAwake ? 39.0 : 36.0, at: ts) + } + + let night = CycleBBTService.nightTemperature(for: session.date, context: context) + XCTAssertEqual(night.celsius, 36.0) + XCTAssertEqual(night.sampleCount, 12) + } + + func testTooFewSamplesReturnsNoData() throws { + let nightStart = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1))! + let session = TestSupport.insertSleep(nightStart: nightStart, stages: Array(repeating: SleepStage.light, count: 300), into: context) + for halfHour in 0..<3 { + insertTemp(36.2, at: calendar.date(byAdding: .minute, value: 15 + halfHour * 30, to: nightStart)!) + } + let night = CycleBBTService.nightTemperature(for: session.date, context: context) + XCTAssertNil(night.celsius) + } + + // MARK: - No-session fallback + + func testFallbackPicksTheMostStableWindow() throws { + // No sleep session at all. Noisy daytime readings, one rock-steady 4h stretch — + // the fallback must find the steady stretch, not a clock window. + let day = TestSupport.day(0) + let windowStart = calendar.date(byAdding: .hour, value: -12, to: day)! + for halfHour in 0..<48 { + let ts = calendar.date(byAdding: .minute, value: halfHour * 30, to: windowStart)! + let hoursIn = Double(halfHour) / 2 + // Stable rest block 5h–9h into the window; jittery everywhere else. + let stable = hoursIn >= 5 && hoursIn < 9 + insertTemp(stable ? 36.2 : (halfHour.isMultiple(of: 2) ? 35.4 : 37.1), at: ts) + } + let night = CycleBBTService.nightTemperature(for: day, context: context) + XCTAssertEqual(night.celsius, 36.2) + } + + func testNoDataAtAllReturnsNil() throws { + let night = CycleBBTService.nightTemperature(for: TestSupport.day(0), context: context) + XCTAssertNil(night.celsius) + XCTAssertEqual(night.sampleCount, 0) + } + + // MARK: - Capability gating + + func testCycleUnavailableWithoutTemperatureCapability() throws { + context.insert(Device(state: .connected, capabilities: [.heartRate, .spo2, .steps])) + try context.save() + XCTAssertFalse(CycleService.isAvailable(context: context)) + } + + func testCycleAvailableWithTemperatureCapability() throws { + context.insert(Device(state: .connected, capabilities: [.heartRate, .temperature])) + try context.save() + XCTAssertTrue(CycleService.isAvailable(context: context)) + } + + // MARK: - Overview plumbing + + func testOverviewMergesLoggedFactsAndTemperatures() throws { + // Period on days -9…-6, one biphasic stretch of nights. + for offset in -9...(-6) { + let day = CycleRepository.dayOrNew(for: TestSupport.day(offset), context: context) + day.isPeriod = true + CycleRepository.save(day, context: context) + } + for offset in -9...0 { + let wake = calendar.date(bySettingHour: 7, minute: 0, second: 0, of: TestSupport.day(offset))! + let nightStart = calendar.date(byAdding: .minute, value: -420, to: wake)! + TestSupport.insertSleep(nightStart: nightStart, stages: Array(repeating: SleepStage.light, count: 420), into: context) + for halfHour in 0..<14 { + insertTemp(36.0, at: calendar.date(byAdding: .minute, value: 15 + halfHour * 30, to: nightStart)!) + } + } + + let overview = CycleService.overview(context: context) + let analysis = try XCTUnwrap(overview.analysis) + XCTAssertEqual(analysis.cycleStart, TestSupport.day(-9)) + XCTAssertEqual(analysis.dayNumber, 10) + XCTAssertEqual(overview.chartDays.count, 10) + XCTAssertEqual(overview.chartDays.compactMap(\.temperature).count, 10) + XCTAssertTrue(overview.chartDays.first?.isPeriod == true) + } + + func testOverviewWithoutPeriodHasNoAnalysisButKeepsFacts() throws { + let day = CycleRepository.dayOrNew(for: TestSupport.day(-1), context: context) + day.isDisturbed = true + CycleRepository.save(day, context: context) + + let overview = CycleService.overview(context: context) + XCTAssertNil(overview.analysis) + XCTAssertEqual(overview.loggedDays.count, 1) + XCTAssertTrue(overview.loggedDays.values.first?.isDisturbed == true) + } + + func testRepositoryPrunesEmptyDays() throws { + let day = CycleRepository.dayOrNew(for: TestSupport.day(0), context: context) + day.isPeriod = true + CycleRepository.save(day, context: context) + XCTAssertEqual(CycleRepository.days(context: context).count, 1) + + let same = CycleRepository.dayOrNew(for: TestSupport.day(0), context: context) + same.isPeriod = false + CycleRepository.save(same, context: context) + XCTAssertTrue(CycleRepository.days(context: context).isEmpty) + } +} From 85e06017d1960c93f659b84dc4a89caf458d6494 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Thu, 2 Jul 2026 10:53:22 +0200 Subject: [PATCH 02/10] Polish cycle visuals: ring marker on the band centerline, centered calendar digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PulseLoop/DesignSystem/CycleCharts.swift | 8 +++++++- PulseLoop/Views/CycleCalendarView.swift | 18 ++++++++++++------ PulseLoop/Views/RootViews.swift | 3 +++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/PulseLoop/DesignSystem/CycleCharts.swift b/PulseLoop/DesignSystem/CycleCharts.swift index 2b81d16e..020a447a 100644 --- a/PulseLoop/DesignSystem/CycleCharts.swift +++ b/PulseLoop/DesignSystem/CycleCharts.swift @@ -23,18 +23,24 @@ struct CyclePhaseRing: View { var body: some View { GeometryReader { proxy in let side = min(proxy.size.width, proxy.size.height) + // Stroke paths are centered on the circle's edge, so inset the track by half the + // line width to keep the band fully inside `side`. The marker then sits exactly on + // the band's centerline at radius (side - lineWidth) / 2. ZStack { Circle() .stroke(PulseColors.cardSoft, lineWidth: lineWidth) + .padding(lineWidth / 2) ForEach(segments) { segment in Circle() .trim(from: segment.start, to: segment.end) .stroke(segment.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) .rotationEffect(.degrees(-90)) + .padding(lineWidth / 2) } Circle() .fill(PulseColors.textPrimary) - .frame(width: lineWidth * 0.62, height: lineWidth * 0.62) + .frame(width: lineWidth + 5, height: lineWidth + 5) + .overlay(Circle().stroke(PulseColors.card, lineWidth: 3)) .offset(y: -(side - lineWidth) / 2) .rotationEffect(.degrees(progress * 360)) center diff --git a/PulseLoop/Views/CycleCalendarView.swift b/PulseLoop/Views/CycleCalendarView.swift index 22bcae7d..370e36ee 100644 --- a/PulseLoop/Views/CycleCalendarView.swift +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -36,32 +36,38 @@ struct CycleMonthCalendar: View { let inFertileWindow = analysis?.fertileWindow?.contains(day) ?? false let ovulation = analysis?.ovulation.estimatedDate.map { calendar.isDate($0, inSameDayAs: day) } ?? false + // A fixed-size circle with the number drawn by the same center-aligned ZStack keeps the + // digit dead-center; the star/disturbed markers live in overlays so they can't skew it. return Button { onSelect(day) } label: { - ZStack(alignment: .topTrailing) { + ZStack { Circle() .fill(background(facts: facts, predicted: isPredictedPeriod, fertile: inFertileWindow)) .overlay(Circle().stroke(isToday ? PulseColors.accent : .clear, lineWidth: 1.5)) + .frame(width: 36, height: 36) Text("\(calendar.component(.day, from: day))") .font(.system(size: 13, weight: facts?.isPeriod == true ? .semibold : .regular)) + .monospacedDigit() .foregroundStyle(facts?.isPeriod == true ? Color.white : (isFuture ? PulseColors.textMuted : PulseColors.textPrimary)) - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity) + .frame(height: 40) + .overlay(alignment: .topTrailing) { if ovulation { Image(systemName: analysis?.ovulation.isConfirmed == true ? "star.fill" : "star") .font(.system(size: 8)) .foregroundStyle(PulseColors.cycleLuteal) - .offset(x: 1, y: -1) + .offset(x: -2, y: 1) } + } + .overlay(alignment: .bottom) { if facts?.isDisturbed == true { Circle() .fill(PulseColors.warning) .frame(width: 5, height: 5) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .offset(y: -2) } } - .frame(height: 40) .opacity(isFuture ? 0.55 : 1) } .buttonStyle(.plain) diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index a25d8712..2edbb51a 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -107,6 +107,9 @@ struct RootAppView: View { if UserDefaults.standard.bool(forKey: "openRecord") { path.append(AppRoute.recordSelect) } + if UserDefaults.standard.bool(forKey: "openCycle") { + path.append(AppRoute.cycleDetail) + } // Re-attach to an in-progress workout left running across launches. liveWorkout.recover() routeDeepLinkIfNeeded() From 8786582775826f71d1fc50503109c945ba9bd0b7 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Thu, 2 Jul 2026 11:00:55 +0200 Subject: [PATCH 03/10] Add a legend under the cycle calendar 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 --- PulseLoop/Views/CycleCalendarView.swift | 41 +++++++++++++++++++++++++ PulseLoop/Views/CycleDetailView.swift | 3 ++ 2 files changed, 44 insertions(+) diff --git a/PulseLoop/Views/CycleCalendarView.swift b/PulseLoop/Views/CycleCalendarView.swift index 370e36ee..356c0c60 100644 --- a/PulseLoop/Views/CycleCalendarView.swift +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -117,6 +117,47 @@ struct CycleMonthCalendar: View { } } +/// Compact legend for the calendar's markers. Fertility items disappear in hormonal- +/// contraception mode, where the thermal analysis (and thus those markers) is paused. +struct CycleCalendarLegend: View { + var showFertility = true + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 14) { + item(label: "Period") { Circle().fill(PulseColors.cycle) } + item(label: "Predicted") { Circle().fill(PulseColors.cycle.opacity(0.25)) } + if showFertility { + item(label: "Fertile window") { Circle().fill(PulseColors.cycleFertile.opacity(0.35)) } + } + } + HStack(spacing: 14) { + if showFertility { + item(label: "Est. ovulation") { + Image(systemName: "star.fill") + .font(.system(size: 8)) + .foregroundStyle(PulseColors.cycleLuteal) + } + } + item(label: "Excluded night") { + Circle().fill(PulseColors.warning).frame(width: 5, height: 5) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func item(label: String, @ViewBuilder symbol: () -> Symbol) -> some View { + HStack(spacing: 5) { + symbol() + .frame(width: 10, height: 10) + Text(label) + .font(.system(size: 10)) + .foregroundStyle(PulseColors.textMuted) + } + } +} + /// Quick log sheet for one day — the *only* manual input the feature asks for: period yes/no, /// disturbed night yes/no, optional note. Saving an all-empty day removes the row. struct CycleLogSheet: View { diff --git a/PulseLoop/Views/CycleDetailView.swift b/PulseLoop/Views/CycleDetailView.swift index 06426f18..d895ad83 100644 --- a/PulseLoop/Views/CycleDetailView.swift +++ b/PulseLoop/Views/CycleDetailView.swift @@ -236,9 +236,12 @@ struct CycleDetailView: View { CycleMonthCalendar(month: displayedMonth, overview: overview, today: Date()) { day in logItem = CycleLogItem(date: day) } + CycleCalendarLegend(showFertility: !hormonal) + .padding(.top, 2) Text("Tap a day to log a period or exclude a disturbed night.") .font(.system(size: 11)) .foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) } } } From 99b57d48f15682982443253257db0265e4d46794 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Wed, 2 Sep 2026 18:58:55 +0200 Subject: [PATCH 04/10] Include cycle days in the full-data JSON archive 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 --- PulseLoop/Persistence/DataArchive.swift | 40 ++++++ .../Persistence/DataArchiveService.swift | 16 ++- .../Settings/PrivacyDataSettingsView.swift | 2 +- PulseLoopTests/DataArchiveTests.swift | 117 +++++++++++++++++- 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 8dddac34..008fc800 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -15,6 +15,9 @@ import SwiftData // `ActivityBucketSample.date`), which would corrupt cross-timezone restores. // - When a model gains a stored property, mirror it here — the round-trip unit test only // guards fields that exist in the DTO. +// - Entities added after format 1 (`cycleDays`) are optional on the envelope: a file written before +// they existed decodes with the key absent (= no rows) instead of failing as damaged. The format +// version is only bumped for changes an older app could not read at all. // - Types are `nonisolated` (the project defaults to MainActor isolation) so encode/decode can run // off the main actor; the model-facing mappers are `@MainActor` because `@Model` classes are not. @@ -53,6 +56,9 @@ nonisolated struct PulseArchive: Codable, Sendable { var coachNotificationRecords: [ArchiveCoachNotificationRecord] var coachSummaries: [ArchiveCoachSummary] var wearableLogs: [ArchiveWearableLog] + /// Menstrual cycle logs — period days, nights excluded from temperature analysis, notes. Added + /// after format 1, so it is optional: older archives omit the key and import as "no rows". + var cycleDays: [ArchiveCycleDay]? /// Raw UserDefaults JSON blobs keyed by their storage key (metric prefs, workout prefs, /// calibration, coach settings, Apple Health prefs). Opaque pass-through — never re-encoded. @@ -1002,3 +1008,37 @@ nonisolated struct ArchiveWearableLog: Codable, Sendable { context.insert(m) } } + +nonisolated struct ArchiveCycleDay: Codable, Sendable { + var dateString: String + var date: Date + var isPeriod: Bool + var isDisturbed: Bool + var disturbedAutoDetected: Bool + var notes: String? + var updatedAt: Date + + @MainActor init(_ m: CycleDay) { + dateString = m.dateString + date = m.date + isPeriod = m.isPeriod + isDisturbed = m.isDisturbed + disturbedAutoDetected = m.disturbedAutoDetected + notes = m.notes + updatedAt = m.updatedAt + } + + @MainActor func insert(into context: ModelContext) { + let m = CycleDay( + date: date, isPeriod: isPeriod, isDisturbed: isDisturbed, + disturbedAutoDetected: disturbedAutoDetected, notes: notes + ) + // The init re-derives date/dateString from the local timezone; restore the exact stored + // values — dateString is the unique upsert key, so a re-derived key could file a night + // under the wrong day (or collide two rows) after a cross-timezone restore. + m.dateString = dateString + m.date = date + m.updatedAt = updatedAt + context.insert(m) + } +} diff --git a/PulseLoop/Persistence/DataArchiveService.swift b/PulseLoop/Persistence/DataArchiveService.swift index e53ce010..43f9268b 100644 --- a/PulseLoop/Persistence/DataArchiveService.swift +++ b/PulseLoop/Persistence/DataArchiveService.swift @@ -118,6 +118,7 @@ enum DataArchiveService { let coachNotificationRecords = try await collect(CoachNotificationRecord.self, context) { ArchiveCoachNotificationRecord($0) } let coachSummaries = try await collect(CoachSummary.self, context) { ArchiveCoachSummary($0) } let wearableLogs = try await collect(WearableLog.self, context) { ArchiveWearableLog($0) } + let cycleDays = try await collect(CycleDay.self, context) { ArchiveCycleDay($0) } var settings: [String: String] = [:] for key in settingsKeys { @@ -150,7 +151,8 @@ enum DataArchiveService { "coachToolCalls": coachToolCalls.count, "coachNotificationRecords": coachNotificationRecords.count, "coachSummaries": coachSummaries.count, - "wearableLogs": wearableLogs.count + "wearableLogs": wearableLogs.count, + "cycleDays": cycleDays.count ] let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" @@ -184,6 +186,7 @@ enum DataArchiveService { coachNotificationRecords: coachNotificationRecords, coachSummaries: coachSummaries, wearableLogs: wearableLogs, + cycleDays: cycleDays, settings: settings, attachments: collectAttachments(from: attachmentsDirectory) ) @@ -288,7 +291,7 @@ enum DataArchiveService { } } - /// Whether any of the 24 model tables has at least one row — gates the destructive + /// Whether any of the 25 archived model tables has at least one row — gates the destructive /// "Replace all data?" confirmation. static func hasAnyData(context: ModelContext) -> Bool { func has(_ type: T.Type) -> Bool { @@ -302,9 +305,10 @@ enum DataArchiveService { || has(ActivityEvent.self) || has(ActivitySensorPollEvent.self) || has(CoachConversation.self) || has(CoachMessage.self) || has(CoachMemory.self) || has(CoachToolCall.self) || has(CoachNotificationRecord.self) || has(CoachSummary.self) || has(WearableLog.self) + || has(CycleDay.self) } - /// Deletes every row of every model in the schema — all 24 types, unlike `SeedData.clearAll` + /// Deletes every row of every archived model — all 25 types, unlike `SeedData.clearAll` /// (which predates six of them). Tracked deletes, no save, and deliberately synchronous — see /// the atomicity note in `importArchive`. static func wipeAllData(context: ModelContext) throws { @@ -332,6 +336,7 @@ enum DataArchiveService { try deleteAll(CoachNotificationRecord.self, context) try deleteAll(CoachSummary.self, context) try deleteAll(WearableLog.self, context) + try deleteAll(CycleDay.self, context) } private static func deleteAll(_ type: T.Type, _ context: ModelContext) throws { @@ -365,6 +370,7 @@ enum DataArchiveService { insert(archive.coachNotificationRecords, context) insert(archive.coachSummaries, context) insert(archive.wearableLogs, context) + insert(archive.cycleDays ?? [], context) } private static func insert(_ rows: [some ArchiveInsertable], _ context: ModelContext) { @@ -400,6 +406,7 @@ enum DataArchiveService { try requireUnique(archive.coachNotificationRecords.map(\.id), entity: "notification record") try requireUnique(archive.coachSummaries.map(\.id), entity: "coach summary") try requireUnique(archive.wearableLogs.map(\.id), entity: "wearable log") + try requireUnique((archive.cycleDays ?? []).map(\.dateString), entity: "cycle day") // Attachment names come from the file — never let one escape coach_attachments/. for attachment in archive.attachments { @@ -473,7 +480,7 @@ enum DataArchiveService { } } -/// Shared shape of the 24 DTOs' model-restoring side, so `insertAll` can chunk generically. +/// Shared shape of the 25 DTOs' model-restoring side, so `insertAll` can chunk generically. @MainActor protocol ArchiveInsertable { func insert(into context: ModelContext) @@ -503,3 +510,4 @@ extension ArchiveCoachToolCall: ArchiveInsertable {} extension ArchiveCoachNotificationRecord: ArchiveInsertable {} extension ArchiveCoachSummary: ArchiveInsertable {} extension ArchiveWearableLog: ArchiveInsertable {} +extension ArchiveCycleDay: ArchiveInsertable {} diff --git a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift index 1c577288..06b8ec26 100644 --- a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift +++ b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift @@ -71,7 +71,7 @@ struct PrivacyDataSettingsView: View { VStack(alignment: .leading, spacing: 22) { SettingsGroup( header: "Backup", - footer: "Export everything — metrics, sleep, workouts, coach history, settings — to a JSON file " + footer: "Export everything — metrics, sleep, workouts, cycle logs, coach history, settings — to a JSON file " + "you can save, AirDrop, or analyze. Importing a backup replaces all data in the app. " + "API keys are never included." ) { diff --git a/PulseLoopTests/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index a9b7ad31..befa8af6 100644 --- a/PulseLoopTests/DataArchiveTests.swift +++ b/PulseLoopTests/DataArchiveTests.swift @@ -2,7 +2,7 @@ import XCTest import SwiftData @testable import PulseLoop -/// Locks down the full-app export/import archive: a complete round trip over all 24 models, +/// Locks down the full-app export/import archive: a complete round trip over all 25 models, /// wipe completeness, version/corruption rejection (without data loss), and the settings + /// attachment side channels. Hermetic — in-memory SwiftData, suite-scoped UserDefaults, temp dirs. @MainActor @@ -14,7 +14,7 @@ final class DataArchiveTests: XCTestCase { (try? context.fetchCount(FetchDescriptor())) ?? -1 } - /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 24. + /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 25. private func insertModelsMissingFromSeed(_ context: ModelContext, deviceId: UUID) { context.insert(BatterySample(percent: 57, timestamp: Date(timeIntervalSince1970: 1_750_000_000))) context.insert(DeviceMeasurementConfig(deviceId: deviceId)) @@ -50,6 +50,7 @@ final class DataArchiveTests: XCTestCase { check(ActivityEvent.self); check(ActivitySensorPollEvent.self); check(CoachConversation.self) check(CoachMessage.self); check(CoachMemory.self); check(CoachToolCall.self) check(CoachNotificationRecord.self); check(CoachSummary.self); check(WearableLog.self) + check(CycleDay.self) } private func makeSuiteDefaults(_ name: String) -> UserDefaults { @@ -127,6 +128,117 @@ final class DataArchiveTests: XCTestCase { } } + // MARK: - Cycle days + + private func fetchCycleDay(_ key: String, _ context: ModelContext) throws -> CycleDay? { + let rows = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.dateString == key })) + XCTAssertLessThanOrEqual(rows.count, 1, "dateString must stay unique after import") + return rows.first + } + + /// Period days, excluded nights and notes are the most sensitive rows in the store, and they + /// joined the archive after its first format shipped — so lock their round trip down on its + /// own: export → clear → import restores every row under its `dateString` key, and importing + /// the same file twice is idempotent. + func testCycleDaysRoundTripByDateKey() async throws { + let context = try TestSupport.makeContext() + let period = CycleDay(date: TestSupport.day(-12), isPeriod: true) + let disturbed = CycleDay( + date: TestSupport.day(-3), isDisturbed: true, disturbedAutoDetected: true, + notes: "Fever 38.5 °C — night excluded" + ) + let noted = CycleDay(date: TestSupport.day(-1), notes: "Spotting") + // Simulate a row logged in a timezone 9h ahead: its stored `date` is that zone's midnight, + // not the local one, while `dateString` is the key computed there. The importer must + // restore both verbatim — re-deriving them through the init would file the night under + // the previous day here. + disturbed.date = disturbed.date.addingTimeInterval(-9 * 3600) + context.insert(period) + context.insert(disturbed) + context.insert(noted) + try context.save() + let periodKey = period.dateString + let notedKey = noted.dateString + let disturbedKey = disturbed.dateString + let disturbedDate = disturbed.date + let disturbedUpdatedAt = disturbed.updatedAt + XCTAssertNotEqual( + disturbedKey, CycleDay.key(for: Calendar.current.startOfDay(for: disturbedDate)), + "fixture must diverge the stored key from what the init would re-derive" + ) + + func assertRestored(file: StaticString = #filePath, line: UInt = #line) throws { + XCTAssertEqual(count(CycleDay.self, context), 3, file: file, line: line) + + let restoredPeriod = try XCTUnwrap(try fetchCycleDay(periodKey, context), file: file, line: line) + XCTAssertTrue(restoredPeriod.isPeriod, file: file, line: line) + XCTAssertFalse(restoredPeriod.isDisturbed, file: file, line: line) + XCTAssertNil(restoredPeriod.notes, file: file, line: line) + + let restoredDisturbed = try XCTUnwrap(try fetchCycleDay(disturbedKey, context), file: file, line: line) + XCTAssertFalse(restoredDisturbed.isPeriod, file: file, line: line) + XCTAssertTrue(restoredDisturbed.isDisturbed, file: file, line: line) + XCTAssertTrue(restoredDisturbed.disturbedAutoDetected, file: file, line: line) + XCTAssertEqual(restoredDisturbed.notes, "Fever 38.5 °C — night excluded", file: file, line: line) + XCTAssertEqual( + restoredDisturbed.date.timeIntervalSince1970, disturbedDate.timeIntervalSince1970, accuracy: 0.005, + "stored date must be restored, not re-normalized to the local startOfDay", file: file, line: line + ) + XCTAssertEqual( + restoredDisturbed.updatedAt.timeIntervalSince1970, disturbedUpdatedAt.timeIntervalSince1970, accuracy: 0.005, + "updatedAt must not be re-stamped to import time", file: file, line: line + ) + + let restoredNoted = try XCTUnwrap(try fetchCycleDay(notedKey, context), file: file, line: line) + XCTAssertEqual(restoredNoted.notes, "Spotting", file: file, line: line) + XCTAssertFalse(restoredNoted.isPeriod, file: file, line: line) + XCTAssertFalse(restoredNoted.isDisturbed, file: file, line: line) + } + + let defaults = makeSuiteDefaults(#function) + let data = try await DataArchiveService.exportArchive( + context: context, defaults: defaults, attachmentsDirectory: try makeTempDirectory() + ) + + // Clear, then restore into the same store — the "restore onto this device" path, where the + // unique dateString keys are deleted and re-inserted within one save. + try DataArchiveService.wipeAllData(context: context) + try context.save() + XCTAssertEqual(count(CycleDay.self, context), 0) + + try await DataArchiveService.importArchive( + data, context: context, defaults: defaults, attachmentsDirectory: try makeTempDirectory(), refreshStores: false + ) + try assertRestored() + + // Importing the same file again must yield the same three rows — not six, not a failed save. + try await DataArchiveService.importArchive( + data, context: context, defaults: defaults, attachmentsDirectory: try makeTempDirectory(), refreshStores: false + ) + try assertRestored() + } + + /// A backup written before cycle tracking existed has no `cycleDays` key at all. It must still + /// import (the key is optional, not a corruption) and — replace-all semantics — leave the store + /// with no cycle days, exactly like every other entity absent from the file. + func testImportAcceptsArchiveWithoutCycleDaysKey() async throws { + let defaults = makeSuiteDefaults(#function) + let exported = try await DataArchiveService.exportArchive( + context: try TestSupport.makeContext(), defaults: defaults, attachmentsDirectory: try makeTempDirectory() + ) + var json = try XCTUnwrap(try JSONSerialization.jsonObject(with: exported) as? [String: Any]) + XCTAssertNotNil(json.removeValue(forKey: "cycleDays"), "export must always write the cycleDays key") + let legacy = try JSONSerialization.data(withJSONObject: json) + + let context = try TestSupport.makeContext() + context.insert(CycleDay(date: TestSupport.day(-2), isPeriod: true)) + try context.save() + try await DataArchiveService.importArchive( + legacy, context: context, defaults: defaults, attachmentsDirectory: try makeTempDirectory(), refreshStores: false + ) + XCTAssertEqual(count(CycleDay.self, context), 0, "replace-all import must not keep rows the file doesn't carry") + } + // MARK: - Wipe completeness func testWipeAllDataCoversAllModels() async throws { @@ -149,6 +261,7 @@ final class DataArchiveTests: XCTestCase { assertEmpty(ActivityEvent.self); assertEmpty(ActivitySensorPollEvent.self); assertEmpty(CoachConversation.self) assertEmpty(CoachMessage.self); assertEmpty(CoachMemory.self); assertEmpty(CoachToolCall.self) assertEmpty(CoachNotificationRecord.self); assertEmpty(CoachSummary.self); assertEmpty(WearableLog.self) + assertEmpty(CycleDay.self) } // MARK: - Rejection without data loss From ee34333841f7ffad9ed93d75945e500e865c8682 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Fri, 4 Sep 2026 10:18:10 +0200 Subject: [PATCH 05/10] Make the nightly BBT robust to replayed samples and split days 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 --- .../Services/Cycle/CycleBBTService.swift | 38 +++++++--- PulseLoopTests/CycleBBTServiceTests.swift | 72 +++++++++++++++++++ 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/PulseLoop/Services/Cycle/CycleBBTService.swift b/PulseLoop/Services/Cycle/CycleBBTService.swift index bebde8dd..86ec47b1 100644 --- a/PulseLoop/Services/Cycle/CycleBBTService.swift +++ b/PulseLoop/Services/Cycle/CycleBBTService.swift @@ -51,17 +51,20 @@ enum CycleBBTService { context: ModelContext ) -> CycleNightTemperature { let calendar = Calendar.current - if let session = sessions.first(where: { calendar.isDate($0.date, inSameDayAs: day) }) { + // The night is the day's longest session: a waking day can also hold naps, or fragments + // of a night split by a >= 60 min gap, whose handful of samples must not stand in for the + // whole night depending on fetch order. + let mainSession = sessions + .filter { calendar.isDate($0.date, inSameDayAs: day) } + .max { $0.totalMinutes < $1.totalMinutes } + if let session = mainSession { let awake = (awakeBySession[session.id] ?? []) .map { block -> ClosedRange in block.startAt...(block.startAt.addingTimeInterval(TimeInterval(block.durationMinutes * 60))) } - let samples = MetricsRepository.measurements( - kind: .temperature, start: session.startAt, end: session.endAt, context: context - ) - .filter { sample in !awake.contains { $0.contains(sample.timestamp) } } - .map(\.value) - .filter { $0 > 0 } + let samples = temperatureSamples(start: session.startAt, end: session.endAt, context: context) + .filter { sample in !awake.contains { $0.contains(sample.timestamp) } } + .map(\.value) return night(day: day, values: samples) } // No sleep session detected (missed sync, unusual schedule). Fall back to the most @@ -79,9 +82,7 @@ enum CycleBBTService { let windowEnd = calendar.date(byAdding: .hour, value: 12, to: day) else { return CycleNightTemperature(date: day, celsius: nil, sampleCount: 0) } - let samples = MetricsRepository.measurements(kind: .temperature, start: windowStart, end: windowEnd, context: context) - .filter { $0.value > 0 } - .sorted { $0.timestamp < $1.timestamp } + let samples = temperatureSamples(start: windowStart, end: windowEnd, context: context) // 4 h at the ring's ~30 min cadence ⇒ 8 samples per window; require enough for a median. let windowSize = 8 guard samples.count >= max(windowSize, minimumSamples) else { @@ -104,6 +105,23 @@ enum CycleBBTService { return night(day: day, values: best.values) } + /// Positive temperature samples in `[start, end]`, oldest first, **one per slot**. The ring + /// replays its log on every sync and stores written before the upsert fix hold many copies of + /// each slot, so collapse by timestamp (latest row wins) before any statistic: a median over + /// duplicates is weighted by sync count, not by time, and drifts from one sync to the next. + /// Also lifts the repository's default 500-row cap, which on such a store silently dropped + /// the first half of the night. + private static func temperatureSamples(start: Date, end: Date, context: ModelContext) -> [Measurement] { + let rows = MetricsRepository.measurements(kind: .temperature, start: start, end: end, limit: 20_000, context: context) + var latestBySlot: [Int: Measurement] = [:] + for row in rows where row.value > 0 { + let slot = Int(row.timestamp.timeIntervalSince1970.rounded()) + if let current = latestBySlot[slot], current.createdAt > row.createdAt { continue } + latestBySlot[slot] = row + } + return latestBySlot.values.sorted { $0.timestamp < $1.timestamp } + } + private static func night(day: Date, values: [Double]) -> CycleNightTemperature { guard values.count >= minimumSamples else { return CycleNightTemperature(date: day, celsius: nil, sampleCount: values.count) diff --git a/PulseLoopTests/CycleBBTServiceTests.swift b/PulseLoopTests/CycleBBTServiceTests.swift index 61bc1a9b..27e505d6 100644 --- a/PulseLoopTests/CycleBBTServiceTests.swift +++ b/PulseLoopTests/CycleBBTServiceTests.swift @@ -52,6 +52,78 @@ final class CycleBBTServiceTests: XCTestCase { XCTAssertNil(night.celsius) } + // MARK: - Robustness against replayed / duplicated samples + + /// A store written before the log-upsert fix holds one copy of each slot per sync — more copies + /// for the slots that existed at the time of the most syncs. A median over those rows is weighted + /// by sync count, not time. One value per slot, whatever the row count. + func testDuplicateRowsDoNotSkewTheMedian() throws { + let nightStart = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1))! + let session = TestSupport.insertSleep(nightStart: nightStart, stages: Array(repeating: SleepStage.light, count: 420), into: context) + // 14 slots: first half warm (36.9), second half cool (36.5); the cool half replayed ×10. + for halfHour in 0..<14 { + let ts = calendar.date(byAdding: .minute, value: 15 + halfHour * 30, to: nightStart)! + let copies = halfHour < 7 ? 1 : 10 + for _ in 0.. Date: Fri, 4 Sep 2026 10:46:35 +0200 Subject: [PATCH 06/10] Let a confirmed shift silence the long-cycle banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PulseLoop/Services/Cycle/CycleAnalyzer.swift | 14 ++++++--- PulseLoop/Services/Cycle/CycleCopy.swift | 3 +- PulseLoopTests/CycleAnalyzerTests.swift | 32 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/PulseLoop/Services/Cycle/CycleAnalyzer.swift b/PulseLoop/Services/Cycle/CycleAnalyzer.swift index 10a511c0..a2d535ce 100644 --- a/PulseLoop/Services/Cycle/CycleAnalyzer.swift +++ b/PulseLoop/Services/Cycle/CycleAnalyzer.swift @@ -471,10 +471,16 @@ enum CycleAnalyzer { temperature > shiftResult.coverline { flags.append(.possiblePregnancy) } - if dayNumber > 60 { - flags.append(.longCycle) - } else if dayNumber >= 35, shift?.status.isConfirmed != true { - flags.append(.noThermalShiftYet) + // Both banners say "still waiting for the shift", so a *confirmed* shift silences them: + // the cycle has (re)started its luteal phase and the confirmation plus the period + // countdown are the useful signal — postpartum return, PCOS and perimenopause routinely + // run past day 60 before ovulating. + if shift?.status.isConfirmed != true { + if dayNumber > 60 { + flags.append(.longCycle) + } else if dayNumber >= 35 { + flags.append(.noThermalShiftYet) + } } return flags } diff --git a/PulseLoop/Services/Cycle/CycleCopy.swift b/PulseLoop/Services/Cycle/CycleCopy.swift index 529f2c10..2b2b49c6 100644 --- a/PulseLoop/Services/Cycle/CycleCopy.swift +++ b/PulseLoop/Services/Cycle/CycleCopy.swift @@ -79,7 +79,8 @@ enum CycleCopy { return "No temperature shift detected this cycle so far. Cycles without a clear shift happen " + "and are usually nothing to worry about." case .longCycle: - return "This cycle is running unusually long. Estimates are paused until a new period is logged." + return "This cycle is running unusually long. Period estimates are on hold until a temperature " + + "shift is confirmed or a new period is logged." } } } diff --git a/PulseLoopTests/CycleAnalyzerTests.swift b/PulseLoopTests/CycleAnalyzerTests.swift index c3775087..c235a926 100644 --- a/PulseLoopTests/CycleAnalyzerTests.swift +++ b/PulseLoopTests/CycleAnalyzerTests.swift @@ -238,6 +238,38 @@ final class CycleAnalyzerTests: XCTestCase { XCTAssertTrue(analysis.flags.contains(.noThermalShiftYet)) } + func testLongCycleWithoutShiftFlagsLongCycle() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: Array(repeating: 36.0, count: 70)), + goal: .understand, today: day(69), calendar: calendar + )! + XCTAssertEqual(analysis.flags, [.longCycle]) + XCTAssertEqual(CycleCopy.headline(analysis, hormonal: false, today: day(69), calendar: calendar), "Long cycle — waiting for data") + } + + /// A late first ovulation (postpartum return, PCOS, perimenopause): once the shift is + /// confirmed the cycle has restarted, so the long-cycle banner must give way to the + /// confirmation and the period countdown instead of hiding them. + func testConfirmedShiftClearsTheLongCycleFlag() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps(lowDays: 65, highDays: 4)), + goal: .understand, today: day(68), calendar: calendar + )! + XCTAssertTrue(analysis.ovulation.isConfirmed) + XCTAssertTrue(analysis.flags.isEmpty) + XCTAssertNotNil(analysis.nextPeriod) + XCTAssertEqual(CycleCopy.headline(analysis, hormonal: false, today: day(68), calendar: calendar), "Ovulation likely confirmed") + } + + func testProbableShiftKeepsTheLongCycleFlag() { + let analysis = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps(lowDays: 65, highDays: 3)), + goal: .understand, today: day(67), calendar: calendar + )! + if case .probable = analysis.ovulation {} else { XCTFail("expected a probable rise, got \(analysis.ovulation)") } + XCTAssertEqual(analysis.flags, [.longCycle]) + } + // MARK: - Copy func testHeadlinePrioritizesPeriodOverEverything() { From 82e6d3bba93179deec5505a572b73058502c32fc Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Fri, 4 Sep 2026 10:59:43 +0200 Subject: [PATCH 07/10] Name the confirmation day and shrink the drawn fertile band once confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PulseLoop/Services/Cycle/CycleAnalyzer.swift | 14 +++++++ PulseLoop/Services/Cycle/CycleCopy.swift | 25 +++++++++++- PulseLoop/Views/CycleCalendarView.swift | 2 +- PulseLoop/Views/CycleCard.swift | 2 +- PulseLoop/Views/CycleDetailView.swift | 6 +-- PulseLoopTests/CycleAnalyzerTests.swift | 42 ++++++++++++++++++++ 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/PulseLoop/Services/Cycle/CycleAnalyzer.swift b/PulseLoop/Services/Cycle/CycleAnalyzer.swift index a2d535ce..e7bb70eb 100644 --- a/PulseLoop/Services/Cycle/CycleAnalyzer.swift +++ b/PulseLoop/Services/Cycle/CycleAnalyzer.swift @@ -132,6 +132,20 @@ struct CycleAnalysis: Equatable { /// Most recent night that looks like fever/disturbance and isn't excluded yet — the UI /// offers a one-tap "exclude this night?" instead of expecting the user to remember. var disturbanceSuggestion: Date? + + /// The fertile span worth *drawing* (ring, chart, calendar). Until the shift is confirmed it + /// is the full, deliberately wide `fertileWindow`. Once confirmed, that window is closed and + /// only the days around the estimated ovulation carry meaning — sperm survival puts the real + /// span at about five days before it — so the band shrinks to J−5…confirmation. On a long + /// cycle (postpartum return, PCOS) a 75-day band said nothing. The analysis (`fertileWindow`, + /// phase) is untouched: this is presentation only. + func drawnFertileWindow(calendar: Calendar = .current) -> ClosedRange? { + guard let fertileWindow else { return nil } + guard case let .confirmed(estimated, _) = ovulation, + let lead = calendar.date(byAdding: .day, value: -5, to: estimated) else { return fertileWindow } + let start = max(fertileWindow.lowerBound, lead) + return start <= fertileWindow.upperBound ? start...fertileWindow.upperBound : fertileWindow + } } // MARK: - Analyzer diff --git a/PulseLoop/Services/Cycle/CycleCopy.swift b/PulseLoop/Services/Cycle/CycleCopy.swift index 2b2b49c6..b9625edc 100644 --- a/PulseLoop/Services/Cycle/CycleCopy.swift +++ b/PulseLoop/Services/Cycle/CycleCopy.swift @@ -49,8 +49,29 @@ enum CycleCopy { } /// "Day 14 · Luteal" — the second line under the headline. - static func subtitle(_ analysis: CycleAnalysis, hormonal: Bool) -> String { - hormonal ? "Day \(analysis.dayNumber) · analysis paused" : "Day \(analysis.dayNumber) · \(analysis.phase.label)" + static func subtitle(_ analysis: CycleAnalysis, hormonal: Bool, today: Date = Date(), calendar: Calendar = .current) -> String { + hormonal + ? "Day \(analysis.dayNumber) · analysis paused" + : "Day \(analysis.dayNumber) · \(phaseLabel(analysis, hormonal: false, today: today, calendar: calendar))" + } + + /// The phase word for the ring center and the subtitle. Sensiplan closes the fertile window + /// on the *evening* of the confirming high day, so on that day "ovulation confirmed" and + /// "fertile window" are both true — name the hinge instead of letting the two read as a + /// contradiction. + static func phaseLabel( + _ analysis: CycleAnalysis, + hormonal: Bool, + today: Date = Date(), + calendar: Calendar = .current + ) -> String { + if hormonal { return "Tracking" } + if analysis.phase == .fertile, + case let .confirmed(_, confirmedOn) = analysis.ovulation, + calendar.isDate(confirmedOn, inSameDayAs: today) { + return "Fertile window · closes tonight" + } + return analysis.phase.label } /// Whether to surface the one-tap "My period started" button: around the predicted date diff --git a/PulseLoop/Views/CycleCalendarView.swift b/PulseLoop/Views/CycleCalendarView.swift index 356c0c60..fca3d248 100644 --- a/PulseLoop/Views/CycleCalendarView.swift +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -33,7 +33,7 @@ struct CycleMonthCalendar: View { let analysis = overview.analysis let isPredictedPeriod = predictedPeriodDays?.contains(day) ?? false - let inFertileWindow = analysis?.fertileWindow?.contains(day) ?? false + let inFertileWindow = analysis?.drawnFertileWindow()?.contains(day) ?? false let ovulation = analysis?.ovulation.estimatedDate.map { calendar.isDate($0, inSameDayAs: day) } ?? false // A fixed-size circle with the number drawn by the same center-aligned ZStack keeps the diff --git a/PulseLoop/Views/CycleCard.swift b/PulseLoop/Views/CycleCard.swift index dc7703ee..0adf0c57 100644 --- a/PulseLoop/Views/CycleCard.swift +++ b/PulseLoop/Views/CycleCard.swift @@ -42,7 +42,7 @@ struct CycleVitalsCard: View { .foregroundStyle(PulseColors.textMuted) Spacer() if let analysis = overview?.analysis { - Text(settings.settings.onHormonalContraception ? "Tracking" : analysis.phase.label) + Text(CycleCopy.phaseLabel(analysis, hormonal: settings.settings.onHormonalContraception)) .font(.system(size: 11, weight: .medium)) .foregroundStyle(PulseColors.textSecondary) .padding(.horizontal, 8).padding(.vertical, 3) diff --git a/PulseLoop/Views/CycleDetailView.swift b/PulseLoop/Views/CycleDetailView.swift index d895ad83..9b64a8f2 100644 --- a/PulseLoop/Views/CycleDetailView.swift +++ b/PulseLoop/Views/CycleDetailView.swift @@ -84,7 +84,7 @@ struct CycleDetailView: View { Text("Day \(analysis.dayNumber)") .font(.system(size: 30, weight: .semibold, design: .rounded)) .foregroundStyle(PulseColors.textPrimary) - Text(hormonal ? "Tracking" : analysis.phase.label) + Text(CycleCopy.phaseLabel(analysis, hormonal: hormonal)) .font(.system(size: 13, weight: .medium)) .foregroundStyle(PulseColors.textSecondary) } @@ -156,7 +156,7 @@ struct CycleDetailView: View { CycleBBTChart( days: overview?.chartDays ?? [], coverline: analysis.coverline, - fertileWindow: analysis.fertileWindow, + fertileWindow: analysis.drawnFertileWindow(), units: units ) } else { @@ -362,7 +362,7 @@ struct CycleDetailView: View { guard !hormonal else { return segments } - if let window = analysis.fertileWindow { + if let window = analysis.drawnFertileWindow(calendar: calendar) { let startIndex = max(0, CycleAnalyzer.daysBetween(analysis.cycleStart, window.lowerBound, calendar: calendar)) let endIndex = CycleAnalyzer.daysBetween(analysis.cycleStart, window.upperBound, calendar: calendar) + 1 if endIndex > startIndex { diff --git a/PulseLoopTests/CycleAnalyzerTests.swift b/PulseLoopTests/CycleAnalyzerTests.swift index c235a926..384f4c16 100644 --- a/PulseLoopTests/CycleAnalyzerTests.swift +++ b/PulseLoopTests/CycleAnalyzerTests.swift @@ -270,6 +270,48 @@ final class CycleAnalyzerTests: XCTestCase { XCTAssertEqual(analysis.flags, [.longCycle]) } + // MARK: - Presentation on the confirmation day + + /// The confirming high day is both "ovulation confirmed" and (until the evening) "fertile": + /// the label must say so rather than read as a contradiction; the next day is plainly luteal. + func testPhaseLabelNamesTheConfirmationDay() { + let days = records(temps: biphasicTemps(lowDays: 20, highDays: 4)) + let onTheDay = CycleAnalyzer.analyze(days: days, goal: .understand, today: day(23), calendar: calendar)! + XCTAssertTrue(onTheDay.ovulation.isConfirmed) + XCTAssertEqual(onTheDay.phase, .fertile) + XCTAssertEqual( + CycleCopy.phaseLabel(onTheDay, hormonal: false, today: day(23), calendar: calendar), + "Fertile window · closes tonight" + ) + XCTAssertEqual( + CycleCopy.subtitle(onTheDay, hormonal: false, today: day(23), calendar: calendar), + "Day 24 · Fertile window · closes tonight" + ) + XCTAssertEqual(CycleCopy.phaseLabel(onTheDay, hormonal: true, today: day(23), calendar: calendar), "Tracking") + + let dayAfter = CycleAnalyzer.analyze(days: days, goal: .understand, today: day(24), calendar: calendar)! + XCTAssertEqual(dayAfter.phase, .luteal) + XCTAssertEqual(CycleCopy.phaseLabel(dayAfter, hormonal: false, today: day(24), calendar: calendar), "Luteal") + } + + /// Once the shift is confirmed the drawn fertile band shrinks to J−5…confirmation; before + /// that, the full (deliberately wide) window is what gets drawn. + func testDrawnFertileWindowShrinksOnceConfirmed() { + let confirmed = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps(lowDays: 20, highDays: 4)), + goal: .understand, today: day(23), calendar: calendar + )! + XCTAssertEqual(confirmed.fertileWindow, day(5)...day(23)) + XCTAssertEqual(confirmed.drawnFertileWindow(calendar: calendar), day(15)...day(23)) + + let probable = CycleAnalyzer.analyze( + days: records(temps: biphasicTemps(lowDays: 20, highDays: 3)), + goal: .understand, today: day(22), calendar: calendar + )! + XCTAssertFalse(probable.ovulation.isConfirmed) + XCTAssertEqual(probable.drawnFertileWindow(calendar: calendar), probable.fertileWindow) + } + // MARK: - Copy func testHeadlinePrioritizesPeriodOverEverything() { From 048217576c465bd576eb0a995ca7c8a65477d8dd Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Fri, 4 Sep 2026 11:12:04 +0200 Subject: [PATCH 08/10] Date ovulation from the first raw high and chart the smoothed series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PulseLoop/DesignSystem/CycleCharts.swift | 11 ++++--- PulseLoop/Services/Cycle/CycleAnalyzer.swift | 18 +++++++++-- PulseLoop/Services/Cycle/CycleService.swift | 34 ++++++++++++++++---- PulseLoop/Views/CycleDetailView.swift | 3 +- PulseLoopTests/CycleAnalyzerTests.swift | 29 +++++++++++++---- PulseLoopTests/CycleBBTServiceTests.swift | 2 ++ 6 files changed, 75 insertions(+), 22 deletions(-) diff --git a/PulseLoop/DesignSystem/CycleCharts.swift b/PulseLoop/DesignSystem/CycleCharts.swift index 020a447a..9142c547 100644 --- a/PulseLoop/DesignSystem/CycleCharts.swift +++ b/PulseLoop/DesignSystem/CycleCharts.swift @@ -53,9 +53,10 @@ struct CyclePhaseRing: View { // MARK: - BBT chart -/// The basal-temperature chart for one cycle: nightly medians joined by a line, excluded -/// (disturbed) nights as hollow points off the line, the coverline as a dashed rule, and the -/// fertile window as a soft band. Values are stored °C and converted for display only. +/// The basal-temperature chart for one cycle: the line runs through the smoothed series the +/// 3-over-6 rule reads (rolling 3-night median), the raw nightly medians are the dots, excluded +/// (disturbed) nights are hollow points off the line, the coverline is a dashed rule, and the +/// fertile window a soft band. Values are stored °C and converted for display only. struct CycleBBTChart: View { let days: [CycleChartDay] let coverline: Double? // °C @@ -70,7 +71,7 @@ struct CycleBBTChart: View { private var excludedDays: [CycleChartDay] { days.filter { $0.temperature != nil && $0.excluded } } private var yDomain: ClosedRange { - let values = days.compactMap(\.temperature).map(display) + let values = days.flatMap { [$0.temperature, $0.smoothedTemperature] }.compactMap { $0 }.map(display) let pad = units == .metric ? 0.2 : 0.4 guard let lo = values.min(), let hi = values.max() else { return units == .metric ? 35.0...37.5 : 95.0...99.5 @@ -94,7 +95,7 @@ struct CycleBBTChart: View { .foregroundStyle(PulseColors.cycleFertile.opacity(0.08)) } ForEach(validDays) { day in - LineMark(x: .value("Day", day.date), y: .value("Temp", display(day.temperature ?? 0))) + LineMark(x: .value("Day", day.date), y: .value("Temp", display(day.smoothedTemperature ?? day.temperature ?? 0))) .foregroundStyle(PulseColors.cycle) .interpolationMethod(.monotone) .lineStyle(StrokeStyle(lineWidth: 2)) diff --git a/PulseLoop/Services/Cycle/CycleAnalyzer.swift b/PulseLoop/Services/Cycle/CycleAnalyzer.swift index e7bb70eb..8365b103 100644 --- a/PulseLoop/Services/Cycle/CycleAnalyzer.swift +++ b/PulseLoop/Services/Cycle/CycleAnalyzer.swift @@ -272,19 +272,31 @@ enum CycleAnalyzer { let coverline = smoothed[(candidate - config.referenceDays).. coverline else { continue } - let estimated = calendar.date(byAdding: .day, value: -1, to: valid[candidate].date) ?? valid[candidate].date + // The smoothing that tames the ring's quantization also lags: the first *raw* value + // above the line usually precedes the first smoothed one by a day. Sensiplan's "first + // higher measurement" is that raw value, and ovulation is dated from it (Heidelberg + // NFP group: on average 0.9 days before it, 81 % within 0–2 days) — so walk back over + // the raw run above the line, at most the smoothing window. Confirmation stays on the + // smoothed series, the conservative side. + var firstHigh = candidate + while firstHigh > 0, candidate - firstHigh < 2, + let raw = valid[firstHigh - 1].temperature, raw > coverline { + firstHigh -= 1 + } + let firstHighDay = valid[firstHigh].date + let estimated = calendar.date(byAdding: .day, value: -1, to: firstHighDay) ?? firstHighDay switch evaluateRise(candidate: candidate, coverline: coverline, values: smoothed, config: config) { case let .confirmed(index): return ShiftResult( coverline: coverline, - firstHighDay: valid[candidate].date, + firstHighDay: firstHighDay, status: .confirmed(estimated: estimated, confirmedOn: valid[index].date) ) case let .pending(highs): // Rise underway at the end of the data. One raised value is noise; from two // consecutive highs we surface it as "probable". Remember the earliest. if highs >= 2, pending == nil { - pending = ShiftResult(coverline: coverline, firstHighDay: valid[candidate].date, + pending = ShiftResult(coverline: coverline, firstHighDay: firstHighDay, status: .probable(estimated: estimated)) } case .failed: diff --git a/PulseLoop/Services/Cycle/CycleService.swift b/PulseLoop/Services/Cycle/CycleService.swift index d37d9e1f..a1a275b1 100644 --- a/PulseLoop/Services/Cycle/CycleService.swift +++ b/PulseLoop/Services/Cycle/CycleService.swift @@ -5,6 +5,9 @@ import SwiftData struct CycleChartDay: Identifiable, Equatable { let date: Date let temperature: Double? // °C, nil = gap + /// The value the 3-over-6 rule actually reads: rolling 3-night median over valid nights. + /// nil on gaps and excluded nights. + let smoothedTemperature: Double? let excluded: Bool // disturbed night — drawn hollow, skipped by the analysis let isPeriod: Bool @@ -93,9 +96,7 @@ enum CycleService { let chartDays: [CycleChartDay] if let start = analysis?.cycleStart { - chartDays = records.filter { $0.date >= start }.map { - CycleChartDay(date: $0.date, temperature: $0.temperature, excluded: $0.isDisturbed, isPeriod: $0.isPeriod) - } + chartDays = makeChartDays(from: records.filter { $0.date >= start }) } else { chartDays = [] } @@ -110,13 +111,32 @@ enum CycleService { let temperatures = CycleBBTService.nightlyTemperatures(days: days, context: context) let logged = CycleRepository.days(context: context) let loggedByKey = Dictionary(uniqueKeysWithValues: logged.map { ($0.dateString, $0) }) - return zip(days, temperatures).map { day, night in + let records = zip(days, temperatures).map { day, night -> CycleDayRecord in let facts = loggedByKey[CycleDay.key(for: day)] - return CycleChartDay( + return CycleDayRecord( date: day, temperature: night.celsius, - excluded: facts?.isDisturbed ?? false, - isPeriod: facts?.isPeriod ?? false + isPeriod: facts?.isPeriod ?? false, + isDisturbed: facts?.isDisturbed ?? false + ) + } + return makeChartDays(from: records) + } + + /// Chart rows for one cycle's records, carrying both the raw nightly median and the smoothed + /// value the analyzer reads (rolling 3-night median over valid nights). The chart draws its + /// line through the smoothed series — the one the 3-over-6 rule evaluates — so a one-quantum + /// dip in a raw median no longer reads as "back to baseline" on a day the rule counts as high. + static func makeChartDays(from records: [CycleDayRecord]) -> [CycleChartDay] { + let valid = records.filter(\.isValidTemperature) + let smoothedByDate = Dictionary(uniqueKeysWithValues: zip(valid.map(\.date), CycleAnalyzer.smoothedValues(valid))) + return records.map { + CycleChartDay( + date: $0.date, + temperature: $0.temperature, + smoothedTemperature: smoothedByDate[$0.date], + excluded: $0.isDisturbed, + isPeriod: $0.isPeriod ) } } diff --git a/PulseLoop/Views/CycleDetailView.swift b/PulseLoop/Views/CycleDetailView.swift index 9b64a8f2..b3be8369 100644 --- a/PulseLoop/Views/CycleDetailView.swift +++ b/PulseLoop/Views/CycleDetailView.swift @@ -163,7 +163,8 @@ struct CycleDetailView: View { CycleBBTChart(days: pastChartDays, coverline: nil, fertileWindow: nil, units: units) } Text(cycleOffset == 0 - ? "Nightly medians of your ring's sleep temperature. Hollow points are excluded nights." + ? "Dots: nightly medians of your ring's sleep temperature (hollow = excluded). " + + "Line: the 3-night rolling median the temperature rule reads." : pastCycleCaption(past)) .font(.system(size: 11)) .foregroundStyle(PulseColors.textMuted) diff --git a/PulseLoopTests/CycleAnalyzerTests.swift b/PulseLoopTests/CycleAnalyzerTests.swift index 384f4c16..bc9adc92 100644 --- a/PulseLoopTests/CycleAnalyzerTests.swift +++ b/PulseLoopTests/CycleAnalyzerTests.swift @@ -66,9 +66,10 @@ final class CycleAnalyzerTests: XCTestCase { guard case let .confirmed(estimated, confirmedOn) = analysis.ovulation else { return XCTFail("expected confirmed, got \(analysis.ovulation)") } - // Raw highs start day 11; the 3-day rolling median delays the smoothed rise to day 12, - // so the estimate lands on day 11 and confirmation on the 3rd smoothed high (day 14). - XCTAssertEqual(estimated, day(10)) + // Raw highs start day 11 — Sensiplan's first higher measurement — so ovulation is dated + // the day before (day 10). The 3-day rolling median delays the smoothed rise to day 12 and + // confirmation lands on the 3rd smoothed high (day 14). + XCTAssertEqual(estimated, day(9)) XCTAssertEqual(confirmedOn, day(13)) XCTAssertEqual(analysis.coverline, 36.0) XCTAssertEqual(analysis.phase, .luteal) @@ -190,8 +191,8 @@ final class CycleAnalyzerTests: XCTestCase { days: records(temps: biphasicTemps()), goal: .understand, today: day(17), calendar: calendar )! - // No history → default 14-day luteal from the estimated ovulation (day 11). - XCTAssertEqual(analysis.nextPeriod?.expected, day(10 + 14)) + // No history → default 14-day luteal from the estimated ovulation (day 10). + XCTAssertEqual(analysis.nextPeriod?.expected, day(9 + 14)) } func testCompletedCyclesDriveTypicalLengthPrediction() { @@ -270,6 +271,21 @@ final class CycleAnalyzerTests: XCTestCase { XCTAssertEqual(analysis.flags, [.longCycle]) } + /// The smoothed series lags the raw one by a day, but Sensiplan dates ovulation from the + /// first *raw* value above the line. An isolated spike before the rise is not that value: + /// the walk-back stops at the first raw value on or below the line. + func testOvulationIsDatedFromTheFirstRawHigh() { + let shift = CycleAnalyzer.detectShift(in: records(temps: biphasicTemps()), cycleStart: day(0), calendar: calendar)! + XCTAssertEqual(shift.firstHighDay, day(10)) + XCTAssertEqual(shift.status.estimatedDate, day(9)) + + var temps = biphasicTemps() + temps[8] = 36.5 // spike at index 8, index 9 low again, sustained rise from index 10 + let spiked = CycleAnalyzer.detectShift(in: records(temps: temps), cycleStart: day(0), calendar: calendar)! + XCTAssertEqual(spiked.firstHighDay, day(10)) + XCTAssertEqual(spiked.status.estimatedDate, day(9)) + } + // MARK: - Presentation on the confirmation day /// The confirming high day is both "ovulation confirmed" and (until the evening) "fertile": @@ -302,7 +318,8 @@ final class CycleAnalyzerTests: XCTestCase { goal: .understand, today: day(23), calendar: calendar )! XCTAssertEqual(confirmed.fertileWindow, day(5)...day(23)) - XCTAssertEqual(confirmed.drawnFertileWindow(calendar: calendar), day(15)...day(23)) + // Raw rise at index 20 → ovulation dated day 19 → drawn band from J−5 = day 14. + XCTAssertEqual(confirmed.drawnFertileWindow(calendar: calendar), day(14)...day(23)) let probable = CycleAnalyzer.analyze( days: records(temps: biphasicTemps(lowDays: 20, highDays: 3)), diff --git a/PulseLoopTests/CycleBBTServiceTests.swift b/PulseLoopTests/CycleBBTServiceTests.swift index 27e505d6..43e496b8 100644 --- a/PulseLoopTests/CycleBBTServiceTests.swift +++ b/PulseLoopTests/CycleBBTServiceTests.swift @@ -186,6 +186,8 @@ final class CycleBBTServiceTests: XCTestCase { XCTAssertEqual(analysis.dayNumber, 10) XCTAssertEqual(overview.chartDays.count, 10) XCTAssertEqual(overview.chartDays.compactMap(\.temperature).count, 10) + XCTAssertEqual(overview.chartDays.compactMap(\.smoothedTemperature), Array(repeating: 36.0, count: 10), + "the chart carries the smoothed series the rule reads") XCTAssertTrue(overview.chartDays.first?.isPeriod == true) } From 2477b3f6ffd1ca6b51a3dc33de67a8c6e26d1662 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Fri, 4 Sep 2026 11:27:19 +0200 Subject: [PATCH 09/10] Make the cycle month calendar readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PulseLoop/Views/CycleCalendarView.swift | 148 +++++++++++--- PulseLoop/Views/CycleDetailView.swift | 2 +- .../CycleCalendarShadingTests.swift | 181 ++++++++++++++++++ 3 files changed, 308 insertions(+), 23 deletions(-) create mode 100644 PulseLoopTests/CycleCalendarShadingTests.swift diff --git a/PulseLoop/Views/CycleCalendarView.swift b/PulseLoop/Views/CycleCalendarView.swift index fca3d248..01b295d6 100644 --- a/PulseLoop/Views/CycleCalendarView.swift +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -1,13 +1,118 @@ import SwiftUI import SwiftData +/// How one day of the month grid is shaded. Precedence is total and top-down — a logged flow day +/// beats a predicted one, which beats the fertile band, which beats the luteal phase — so every +/// day resolves to exactly one tint. Kept out of the view (and free of SwiftUI) so the date +/// arithmetic can be unit-tested on its own. +enum CycleCalendarShading: Equatable { + case period + case predictedPeriod + case fertile + case luteal + case none + + /// A predicted period is drawn as the expected day plus a typical flow length. + static let predictedFlowDays = 5 + + /// Classify one day against the logged facts and the derived analysis. + static func shading( + for day: Date, + overview: CycleOverview, + today: Date, + calendar: Calendar = .current + ) -> CycleCalendarShading { + let day = calendar.startOfDay(for: day) + let today = calendar.startOfDay(for: today) + if overview.loggedDays[CycleDay.key(for: day)]?.isPeriod == true { return .period } + guard let analysis = overview.analysis else { return .none } + if isPredictedPeriod(day, analysis: analysis, today: today, calendar: calendar) { return .predictedPeriod } + if analysis.drawnFertileWindow(calendar: calendar)?.contains(day) == true { return .fertile } + if isLuteal(day, analysis: analysis, today: today, calendar: calendar) { return .luteal } + return .none + } + + /// Expected flow: the predicted start plus a typical period, future only — up to today the + /// calendar shows what the user actually logged, not what was forecast. + private static func isPredictedPeriod(_ day: Date, analysis: CycleAnalysis, today: Date, calendar: Calendar) -> Bool { + guard let expected = analysis.nextPeriod?.expected, expected > today, day >= expected else { return false } + return CycleAnalyzer.daysBetween(expected, day, calendar: calendar) < predictedFlowDays + } + + /// Luteal days of the running cycle *and* of every past cycle whose shift was detected. + private static func isLuteal(_ day: Date, analysis: CycleAnalysis, today: Date, calendar: Calendar) -> Bool { + if currentLutealRange(analysis, today: today, calendar: calendar)?.contains(day) == true { return true } + return analysis.completedCycles.contains { completedLutealRange($0, calendar: calendar)?.contains(day) == true } + } + + /// The running cycle's luteal band: from the day after the confirming high — the very test + /// `CycleAnalyzer` uses to switch the phase — to the day before the next period. Only a + /// *confirmed* shift draws it; while the rise is merely probable the days still read fertile. + /// A prediction carries the band into the future; without one it stops at today. + private static func currentLutealRange(_ analysis: CycleAnalysis, today: Date, calendar: Calendar) -> ClosedRange? { + guard case let .confirmed(_, confirmedOn) = analysis.ovulation, + let start = calendar.date(byAdding: .day, value: 1, to: confirmedOn) else { return nil } + let end = analysis.nextPeriod.flatMap { calendar.date(byAdding: .day, value: -1, to: $0.expected) } ?? today + return start <= end ? start...end : nil + } + + /// A closed cycle's luteal band: the day after its estimated ovulation through the day before + /// the period that ended it. + private static func completedLutealRange(_ cycle: CompletedCycleSummary, calendar: Calendar) -> ClosedRange? { + guard let ovulationIndex = cycle.ovulationDayIndex, + let start = calendar.date(byAdding: .day, value: ovulationIndex + 1, to: cycle.start), + let end = calendar.date(byAdding: .day, value: cycle.lengthDays - 1, to: cycle.start), + start <= end else { return nil } + return start...end + } +} + +/// Shading → paint. The opacities here are the *final* rendered strength, so no caller may dim a +/// cell on top of them: that double-dimming is exactly what washed predicted days out to nothing. +private extension CycleCalendarShading { + var fill: Color { + switch self { + case .period: return PulseColors.cycle + case .predictedPeriod: return PulseColors.cycle.opacity(0.22) + case .fertile: return PulseColors.cycleFertile.opacity(0.30) + case .luteal: return PulseColors.cycleLuteal.opacity(0.26) + case .none: return PulseColors.cardSoft.opacity(0.5) + } + } + + /// Only predicted flow carries a ring — dashed, in the period color — so "expected" is told + /// apart from "logged" by shape, not merely by how strong the pink is. + var ring: (color: Color, style: StrokeStyle)? { + guard self == .predictedPeriod else { return nil } + return (PulseColors.cycle.opacity(0.85), StrokeStyle(lineWidth: 1, dash: [2.5, 2.5])) + } +} + +/// The circle a day of the grid is drawn with. Shared with the legend so a swatch can never drift +/// from the cells it explains. +private struct CycleShadingCircle: View { + let shading: CycleCalendarShading + + var body: some View { + Circle() + .fill(shading.fill) + .overlay { + if let ring = shading.ring { + Circle().strokeBorder(ring.color, style: ring.style) + } + } + } +} + /// Month grid for the cycle detail screen: logged period days filled, predicted period days -/// tinted, the fertile window softly highlighted, ovulation starred, disturbed nights dotted. -/// Tapping any past-or-today day opens the quick log sheet. +/// tinted and dashed, the fertile window and the luteal phase highlighted, ovulation starred, +/// disturbed nights dotted. Tapping any past-or-today day opens the quick log sheet. struct CycleMonthCalendar: View { let month: Date // any day inside the displayed month let overview: CycleOverview let today: Date + /// Off under hormonal contraception, where the thermal analysis is paused. + var showFertility = true let onSelect: (Date) -> Void private var calendar: Calendar { Calendar.current } @@ -31,25 +136,24 @@ struct CycleMonthCalendar: View { let isToday = calendar.isDate(day, inSameDayAs: today) let isFuture = day > today let analysis = overview.analysis - - let isPredictedPeriod = predictedPeriodDays?.contains(day) ?? false - let inFertileWindow = analysis?.drawnFertileWindow()?.contains(day) ?? false + let shading = shading(for: day) let ovulation = analysis?.ovulation.estimatedDate.map { calendar.isDate($0, inSameDayAs: day) } ?? false // A fixed-size circle with the number drawn by the same center-aligned ZStack keeps the // digit dead-center; the star/disturbed markers live in overlays so they can't skew it. + // A future day is dimmed through its *digit* only: fading the whole cell multiplied with + // the fill's own opacity and left the predicted period at ~0.11, i.e. invisible. return Button { onSelect(day) } label: { ZStack { - Circle() - .fill(background(facts: facts, predicted: isPredictedPeriod, fertile: inFertileWindow)) + CycleShadingCircle(shading: shading) .overlay(Circle().stroke(isToday ? PulseColors.accent : .clear, lineWidth: 1.5)) .frame(width: 36, height: 36) Text("\(calendar.component(.day, from: day))") - .font(.system(size: 13, weight: facts?.isPeriod == true ? .semibold : .regular)) + .font(.system(size: 13, weight: shading == .period ? .semibold : .regular)) .monospacedDigit() - .foregroundStyle(facts?.isPeriod == true ? Color.white : (isFuture ? PulseColors.textMuted : PulseColors.textPrimary)) + .foregroundStyle(digitColor(shading: shading, isFuture: isFuture)) } .frame(maxWidth: .infinity) .frame(height: 40) @@ -68,23 +172,22 @@ struct CycleMonthCalendar: View { .frame(width: 5, height: 5) } } - .opacity(isFuture ? 0.55 : 1) } .buttonStyle(.plain) .disabled(isFuture) } - private func background(facts: CycleOverview.CycleDayFacts?, predicted: Bool, fertile: Bool) -> Color { - if facts?.isPeriod == true { return PulseColors.cycle } - if predicted { return PulseColors.cycle.opacity(0.20) } - if fertile { return PulseColors.cycleFertile.opacity(0.12) } - return PulseColors.cardSoft.opacity(0.5) + /// Fertility shading (fertile band, luteal phase) is dropped under hormonal contraception, + /// where the thermal analysis is paused — the same gate the chart and the legend already use. + private func shading(for day: Date) -> CycleCalendarShading { + let shading = CycleCalendarShading.shading(for: day, overview: overview, today: today, calendar: calendar) + if !showFertility, shading == .fertile || shading == .luteal { return .none } + return shading } - /// Predicted flow days: the expected start plus a typical 5-day period, future only. - private var predictedPeriodDays: [Date]? { - guard let expected = overview.analysis?.nextPeriod?.expected, expected > today else { return nil } - return (0..<5).compactMap { calendar.date(byAdding: .day, value: $0, to: expected) } + private func digitColor(shading: CycleCalendarShading, isFuture: Bool) -> Color { + if shading == .period { return .white } + return isFuture ? PulseColors.textMuted : PulseColors.textPrimary } // MARK: - Month math @@ -125,14 +228,15 @@ struct CycleCalendarLegend: View { var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 14) { - item(label: "Period") { Circle().fill(PulseColors.cycle) } - item(label: "Predicted") { Circle().fill(PulseColors.cycle.opacity(0.25)) } + item(label: "Period") { CycleShadingCircle(shading: .period) } + item(label: "Predicted") { CycleShadingCircle(shading: .predictedPeriod) } if showFertility { - item(label: "Fertile window") { Circle().fill(PulseColors.cycleFertile.opacity(0.35)) } + item(label: "Fertile window") { CycleShadingCircle(shading: .fertile) } } } HStack(spacing: 14) { if showFertility { + item(label: "Luteal") { CycleShadingCircle(shading: .luteal) } item(label: "Est. ovulation") { Image(systemName: "star.fill") .font(.system(size: 8)) diff --git a/PulseLoop/Views/CycleDetailView.swift b/PulseLoop/Views/CycleDetailView.swift index b3be8369..9588ba44 100644 --- a/PulseLoop/Views/CycleDetailView.swift +++ b/PulseLoop/Views/CycleDetailView.swift @@ -234,7 +234,7 @@ struct CycleDetailView: View { } .foregroundStyle(PulseColors.textSecondary) - CycleMonthCalendar(month: displayedMonth, overview: overview, today: Date()) { day in + CycleMonthCalendar(month: displayedMonth, overview: overview, today: Date(), showFertility: !hormonal) { day in logItem = CycleLogItem(date: day) } CycleCalendarLegend(showFertility: !hormonal) diff --git a/PulseLoopTests/CycleCalendarShadingTests.swift b/PulseLoopTests/CycleCalendarShadingTests.swift new file mode 100644 index 00000000..e9de6bcc --- /dev/null +++ b/PulseLoopTests/CycleCalendarShadingTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import PulseLoop + +/// Classification tests for the month calendar's day shading: which tint a day gets, and in what +/// order the rules win. Fixtures run through `CycleAnalyzer.analyze` over synthetic +/// `CycleDayRecord`s — the pipeline `CycleService` uses at runtime — so the expectations track +/// the real analysis instead of a hand-written stub. +@MainActor +final class CycleCalendarShadingTests: XCTestCase { + private let calendar = Calendar.current + private lazy var base = calendar.startOfDay(for: Date()) + + private func day(_ offset: Int) -> Date { + calendar.date(byAdding: .day, value: offset, to: base)! + } + + /// Records for cycle day 1…n (day 1 == offset 0): `temps[i]` maps to day i+1. + private func records(temps: [Double?], periodDays: Set = [1, 2, 3, 4]) -> [CycleDayRecord] { + temps.enumerated().map { index, temp in + CycleDayRecord( + date: day(index), + temperature: temp, + isPeriod: periodDays.contains(index + 1), + isDisturbed: false + ) + } + } + + /// A textbook biphasic cycle: low nights then a sustained rise. + private func biphasicTemps(lowDays: Int = 10, highDays: Int = 8, low: Double = 36.0, high: Double = 36.5) -> [Double?] { + Array(repeating: low, count: lowDays) + Array(repeating: high, count: highDays) + } + + /// The overview the calendar receives: analysis plus logged facts, both off the same records. + private func makeOverview(_ records: [CycleDayRecord], today: Date, goal: CycleGoal = .understand) -> CycleOverview { + var facts: [String: CycleOverview.CycleDayFacts] = [:] + for record in records { + facts[CycleDay.key(for: record.date)] = CycleOverview.CycleDayFacts( + isPeriod: record.isPeriod, isDisturbed: record.isDisturbed, hasNote: false + ) + } + return CycleOverview( + analysis: CycleAnalyzer.analyze(days: records, goal: goal, today: today, calendar: calendar), + chartDays: [], + loggedDays: facts + ) + } + + /// The cycle most tests read: period on days 1–4, 10 low nights, then a sustained rise. + /// Ovulation lands on day(9) and is confirmed on day(13) — see `testReferenceCycleAnchors`. + private func idealCycle(today: Date) -> CycleOverview { + makeOverview(records(temps: biphasicTemps()), today: today) + } + + private func shading(_ overview: CycleOverview, _ day: Date, today: Date) -> CycleCalendarShading { + CycleCalendarShading.shading(for: day, overview: overview, today: today, calendar: calendar) + } + + // MARK: - Fixture anchors + + func testReferenceCycleAnchors() { + let today = day(17) + let analysis = idealCycle(today: today).analysis! + guard case let .confirmed(estimated, confirmedOn) = analysis.ovulation else { + return XCTFail("expected confirmed, got \(analysis.ovulation)") + } + XCTAssertEqual(estimated, day(9)) + XCTAssertEqual(confirmedOn, day(13)) + // Default 14-day luteal from the estimate: the period is expected on day(23), so the + // luteal band the calendar draws runs day(14)…day(22). + XCTAssertEqual(analysis.nextPeriod?.expected, day(23)) + XCTAssertEqual(analysis.drawnFertileWindow(calendar: calendar), day(5)...day(13)) + } + + // MARK: - Logged period + + func testLoggedPeriodDaysShadeAsPeriod() { + let today = day(17) + let overview = idealCycle(today: today) + for offset in 0...3 { + XCTAssertEqual(shading(overview, day(offset), today: today), .period, "day(\(offset))") + } + XCTAssertNotEqual(shading(overview, day(4), today: today), .period) + } + + // MARK: - Predicted period + + func testPredictedPeriodCoversExpectedStartPlusFlowDays() { + let today = day(17) + let overview = idealCycle(today: today) + XCTAssertEqual(shading(overview, day(22), today: today), .luteal) // day before the prediction + XCTAssertEqual(shading(overview, day(23), today: today), .predictedPeriod) + XCTAssertEqual(shading(overview, day(27), today: today), .predictedPeriod) + XCTAssertEqual(shading(overview, day(28), today: today), .none) + } + + func testExpectedPeriodAlreadyInThePastIsNotShaded() { + // The period never arrived: past days show what was logged, not what was forecast. + let today = day(30) + let overview = idealCycle(today: today) + XCTAssertEqual(overview.analysis?.nextPeriod?.expected, day(23)) + XCTAssertEqual(shading(overview, day(23), today: today), .none) + XCTAssertEqual(shading(overview, day(22), today: today), .luteal) + } + + // MARK: - Fertile window + + func testFertileWindowDaysShadeAsFertile() { + let today = day(17) + let overview = idealCycle(today: today) + XCTAssertEqual(shading(overview, day(4), today: today), .none) // just before the window + XCTAssertEqual(shading(overview, day(5), today: today), .fertile) + XCTAssertEqual(shading(overview, day(13), today: today), .fertile) // the confirming high closes it + XCTAssertEqual(shading(overview, day(14), today: today), .luteal) + } + + // MARK: - Luteal phase + + func testConfirmedShiftShadesLutealBeforeAndAfterToday() { + let today = day(17) + let overview = idealCycle(today: today) + XCTAssertEqual(shading(overview, day(14), today: today), .luteal) // past + XCTAssertEqual(shading(overview, day(17), today: today), .luteal) // today + XCTAssertEqual(shading(overview, day(20), today: today), .luteal) // future + XCTAssertEqual(shading(overview, day(22), today: today), .luteal) // last day before the prediction + } + + func testProbableShiftDrawsNoLuteal() { + // Three raw highs only: the rise is underway but unconfirmed, so those days stay fertile + // or plain — the luteal phase is the *infertile* reading and must not be claimed early. + let today = day(12) + let overview = makeOverview(records(temps: biphasicTemps(highDays: 3)), today: today) + guard case .probable = overview.analysis?.ovulation ?? .notDetected else { + return XCTFail("expected probable, got \(String(describing: overview.analysis?.ovulation))") + } + XCTAssertEqual(shading(overview, day(10), today: today), .fertile) + XCTAssertEqual(shading(overview, day(11), today: today), .none) + XCTAssertEqual(shading(overview, day(12), today: today), .none) + } + + func testCompletedCycleLutealRangeIsShaded() { + // A closed 28-day cycle (biphasic, ovulation on index 9) followed by a fresh period on + // day(28): the calendar keeps drawing the old cycle's luteal half in the month grid. + let temps: [Double?] = Array(repeating: 36.0, count: 10) + + Array(repeating: 36.5, count: 18) + + Array(repeating: 36.0, count: 12) + let today = day(35) + let overview = makeOverview(records(temps: temps, periodDays: [1, 2, 3, 4, 29, 30, 31, 32]), today: today) + let completed = overview.analysis!.completedCycles + XCTAssertEqual(completed.map(\.lengthDays), [28]) + XCTAssertEqual(completed.first?.ovulationDayIndex, 9) + + XCTAssertEqual(shading(overview, day(9), today: today), .none) // the estimate itself + XCTAssertEqual(shading(overview, day(10), today: today), .luteal) + XCTAssertEqual(shading(overview, day(27), today: today), .luteal) // day before the next period + XCTAssertEqual(shading(overview, day(28), today: today), .period) + } + + // MARK: - Precedence + + func testLoggedPeriodWinsOverLuteal() { + // The analyzer would open a new cycle on a flow day this late, so the fact is injected + // straight into the overview: what is under test is the precedence rule, not the fixture. + let today = day(17) + var overview = idealCycle(today: today) + XCTAssertEqual(shading(overview, day(16), today: today), .luteal) + overview.loggedDays[CycleDay.key(for: day(16))] = CycleOverview.CycleDayFacts( + isPeriod: true, isDisturbed: false, hasNote: false + ) + XCTAssertEqual(shading(overview, day(16), today: today), .period) + } + + // MARK: - No analysis + + func testWithoutAnalysisEveryDayIsPlain() { + let today = day(17) + let empty = CycleOverview(analysis: nil, chartDays: [], loggedDays: [:]) + XCTAssertEqual(shading(empty, day(0), today: today), .none) + XCTAssertEqual(shading(empty, day(20), today: today), .none) + } +} From 6c87cc6cfe31c13fb811067091790651c184a761 Mon Sep 17 00:00:00 2001 From: Henri Bruvier Date: Fri, 4 Sep 2026 11:33:57 +0200 Subject: [PATCH 10/10] Shade the predicted period when it is due today 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 --- PulseLoop/Views/CycleCalendarView.swift | 10 +++++++--- PulseLoopTests/CycleCalendarShadingTests.swift | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/PulseLoop/Views/CycleCalendarView.swift b/PulseLoop/Views/CycleCalendarView.swift index 01b295d6..82817815 100644 --- a/PulseLoop/Views/CycleCalendarView.swift +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -32,10 +32,14 @@ enum CycleCalendarShading: Equatable { return .none } - /// Expected flow: the predicted start plus a typical period, future only — up to today the - /// calendar shows what the user actually logged, not what was forecast. + /// Expected flow: the predicted start plus a typical period, drawn as soon as the expected day + /// is today or later — the day the headline reads "Period due today" the flow is shaded from + /// today on. Once the expected day has passed the period is late and nothing is drawn: past + /// days show what the user actually logged, not what was forecast. private static func isPredictedPeriod(_ day: Date, analysis: CycleAnalysis, today: Date, calendar: Calendar) -> Bool { - guard let expected = analysis.nextPeriod?.expected, expected > today, day >= expected else { return false } + guard let predicted = analysis.nextPeriod?.expected else { return false } + let expected = calendar.startOfDay(for: predicted) + guard expected >= today, day >= expected else { return false } return CycleAnalyzer.daysBetween(expected, day, calendar: calendar) < predictedFlowDays } diff --git a/PulseLoopTests/CycleCalendarShadingTests.swift b/PulseLoopTests/CycleCalendarShadingTests.swift index e9de6bcc..9f18d58b 100644 --- a/PulseLoopTests/CycleCalendarShadingTests.swift +++ b/PulseLoopTests/CycleCalendarShadingTests.swift @@ -94,8 +94,21 @@ final class CycleCalendarShadingTests: XCTestCase { XCTAssertEqual(shading(overview, day(28), today: today), .none) } + func testExpectedPeriodDueTodayShadesTodayAndTheFlowDays() { + // The day the headline reads "Period due today": the flow is drawn from today on, not + // skipped for landing on the boundary. + let today = day(23) + let overview = idealCycle(today: today) + XCTAssertEqual(overview.analysis?.nextPeriod?.expected, today) + XCTAssertEqual(shading(overview, day(22), today: today), .luteal) // day before the prediction + for offset in 23...27 { + XCTAssertEqual(shading(overview, day(offset), today: today), .predictedPeriod, "day(\(offset))") + } + XCTAssertEqual(shading(overview, day(28), today: today), .none) + } + func testExpectedPeriodAlreadyInThePastIsNotShaded() { - // The period never arrived: past days show what was logged, not what was forecast. + // The period is late: past days show what was logged, not what was forecast. let today = day(30) let overview = idealCycle(today: today) XCTAssertEqual(overview.analysis?.nextPeriod?.expected, day(23))