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..9142c547 --- /dev/null +++ b/PulseLoop/DesignSystem/CycleCharts.swift @@ -0,0 +1,147 @@ +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) + // 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 + 5, height: lineWidth + 5) + .overlay(Circle().stroke(PulseColors.card, lineWidth: 3)) + .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: 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 + 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.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 + } + 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.smoothedTemperature ?? 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/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/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..8365b103 --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleAnalyzer.swift @@ -0,0 +1,538 @@ +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? + + /// 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 + +/// 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 } + + // 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: 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: firstHighDay, + 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) + } + // 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 + } + + /// 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..86ec47b1 --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleBBTService.swift @@ -0,0 +1,140 @@ +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 + // 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 = 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 + // *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 = 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 { + 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) + } + + /// 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) + } + 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..b9625edc --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleCopy.swift @@ -0,0 +1,107 @@ +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, 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 + /// (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. Period estimates are on hold until a temperature " + + "shift is confirmed or 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..a1a275b1 --- /dev/null +++ b/PulseLoop/Services/Cycle/CycleService.swift @@ -0,0 +1,143 @@ +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 + /// 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 + + 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 = makeChartDays(from: records.filter { $0.date >= start }) + } 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.. CycleDayRecord in + let facts = loggedByKey[CycleDay.key(for: day)] + return CycleDayRecord( + date: day, + temperature: night.celsius, + 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/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index b9fb0f47..14dcda23 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -290,6 +290,44 @@ enum MeasurementConfigRepository { } } +/// User-logged cycle facts, keyed by local calendar day. Sensitive data — read only by the +/// cycle feature (and the coach context builder behind an explicit opt-in). +enum CycleRepository { + @MainActor + static func days(context: ModelContext) -> [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..82817815 --- /dev/null +++ b/PulseLoop/Views/CycleCalendarView.swift @@ -0,0 +1,348 @@ +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, 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 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 + } + + /// 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 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 } + + 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 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 { + 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: shading == .period ? .semibold : .regular)) + .monospacedDigit() + .foregroundStyle(digitColor(shading: shading, isFuture: isFuture)) + } + .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: -2, y: 1) + } + } + .overlay(alignment: .bottom) { + if facts?.isDisturbed == true { + Circle() + .fill(PulseColors.warning) + .frame(width: 5, height: 5) + } + } + } + .buttonStyle(.plain) + .disabled(isFuture) + } + + /// 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 + } + + private func digitColor(shading: CycleCalendarShading, isFuture: Bool) -> Color { + if shading == .period { return .white } + return isFuture ? PulseColors.textMuted : PulseColors.textPrimary + } + + // 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..(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 { + let date: Date + let onSaved: () -> 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..0adf0c57 --- /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(CycleCopy.phaseLabel(analysis, hormonal: settings.settings.onHormonalContraception)) + .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..9588ba44 --- /dev/null +++ b/PulseLoop/Views/CycleDetailView.swift @@ -0,0 +1,429 @@ +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(CycleCopy.phaseLabel(analysis, hormonal: hormonal)) + .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.drawnFertileWindow(), + units: units + ) + } else { + CycleBBTChart(days: pastChartDays, coverline: nil, fertileWindow: nil, units: units) + } + Text(cycleOffset == 0 + ? "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) + } + } + } + + 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(), showFertility: !hormonal) { 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) + } + } + } + + // 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.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 { + 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..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() @@ -172,6 +175,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/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/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..bc9adc92 --- /dev/null +++ b/PulseLoopTests/CycleAnalyzerTests.swift @@ -0,0 +1,362 @@ +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 — 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) + } + + 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 10). + XCTAssertEqual(analysis.nextPeriod?.expected, day(9 + 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)) + } + + 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]) + } + + /// 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": + /// 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)) + // 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)), + goal: .understand, today: day(22), calendar: calendar + )! + XCTAssertFalse(probable.ovulation.isConfirmed) + XCTAssertEqual(probable.drawnFertileWindow(calendar: calendar), probable.fertileWindow) + } + + // 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..43e496b8 --- /dev/null +++ b/PulseLoopTests/CycleBBTServiceTests.swift @@ -0,0 +1,216 @@ +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: - 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..= 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) + 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) + } + + 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) + } +} diff --git a/PulseLoopTests/CycleCalendarShadingTests.swift b/PulseLoopTests/CycleCalendarShadingTests.swift new file mode 100644 index 00000000..9f18d58b --- /dev/null +++ b/PulseLoopTests/CycleCalendarShadingTests.swift @@ -0,0 +1,194 @@ +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 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 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)) + 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) + } +} 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