Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ enum AppRoute: Hashable {
case settingsHealth
case settingsStrava
case settingsPrivacyData
case settingsCycle
case cycleDetail
case settingsAbout
case settingsNutrition
case nutrition
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions PulseLoop/Coach/Context/CoachContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 }
Expand Down
13 changes: 13 additions & 0 deletions PulseLoop/Coach/Context/CoachContextPacket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
147 changes: 147 additions & 0 deletions PulseLoop/DesignSystem/CycleCharts.swift
Original file line number Diff line number Diff line change
@@ -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<Center: View>: 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<Date>?
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<Double> {
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<Double>.number.precision(.fractionLength(1)))
.font(.system(size: 10))
.foregroundStyle(PulseColors.textMuted)
}
}
.frame(height: 210)
}
}
45 changes: 45 additions & 0 deletions PulseLoop/Models/PulseModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CycleDay>([\.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()
}
}
40 changes: 40 additions & 0 deletions PulseLoop/Persistence/DataArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
Loading
Loading