From 3df8ac597bdce493863ed86c043169ec03fec5fe Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:57:47 -0800 Subject: [PATCH] feat(wellness): add sleep manager (HealthKit, bedtime routine, smart alarm) + mood/energy check-in with weekly insights --- .../Shared/Wellness/MoodCheckInManager.swift | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 ios/ChronoMind/Shared/Wellness/MoodCheckInManager.swift diff --git a/ios/ChronoMind/Shared/Wellness/MoodCheckInManager.swift b/ios/ChronoMind/Shared/Wellness/MoodCheckInManager.swift new file mode 100644 index 0000000..c219cfc --- /dev/null +++ b/ios/ChronoMind/Shared/Wellness/MoodCheckInManager.swift @@ -0,0 +1,237 @@ +// ── Mood / Energy Check-In Manager ─────────────────────────── +// Quick post-timer energy check-in with weekly insights +// All data stored locally — never sent to any server + +import Foundation +import Combine + +@MainActor +final class MoodCheckInManager: ObservableObject { + static let shared = MoodCheckInManager() + + @Published var checkIns: [MoodCheckIn] = [] + @Published var enabled: Bool { + didSet { UserDefaults.standard.set(enabled, forKey: enabledKey) } + } + + private let storageKey = "chronomind-mood-checkins" + private let enabledKey = "chronomind-mood-enabled" + + private init() { + enabled = UserDefaults.standard.bool(forKey: enabledKey) + loadCheckIns() + } + + // MARK: - Record Check-In + + func record(energy: EnergyLevel, timerId: String?, timerLabel: String?, context: CheckInContext = .postTimer) { + let checkIn = MoodCheckIn( + energy: energy, + timerId: timerId, + timerLabel: timerLabel, + context: context, + timestamp: Date(), + hourOfDay: Calendar.current.component(.hour, from: Date()), + dayOfWeek: Calendar.current.component(.weekday, from: Date()) + ) + checkIns.append(checkIn) + + // Keep last 500 check-ins (~2 months at 8/day) + if checkIns.count > 500 { + checkIns = Array(checkIns.suffix(500)) + } + + saveCheckIns() + } + + // MARK: - Insights + + /// Weekly insight: best productivity hours + func weeklyInsight() -> WeeklyMoodInsight? { + let calendar = Calendar.current + let weekAgo = calendar.date(byAdding: .day, value: -7, to: Date())! + let recentCheckIns = checkIns.filter { $0.timestamp >= weekAgo } + + guard recentCheckIns.count >= 5 else { return nil } + + // Group by hour of day + let byHour = Dictionary(grouping: recentCheckIns, by: \.hourOfDay) + let hourAverages = byHour.mapValues { entries -> Double in + let sum = entries.reduce(0.0) { $0 + Double($1.energy.numericValue) } + return sum / Double(entries.count) + } + + // Find peak productivity window + let sortedHours = hourAverages.sorted { $0.value > $1.value } + let peakHours = sortedHours.prefix(3).map(\.key).sorted() + + // Average energy + let totalEnergy = recentCheckIns.reduce(0.0) { $0 + Double($1.energy.numericValue) } + let avgEnergy = totalEnergy / Double(recentCheckIns.count) + + // Energy distribution + let distribution = Dictionary(grouping: recentCheckIns, by: \.energy) + .mapValues(\.count) + + // Best day of week + let byDay = Dictionary(grouping: recentCheckIns, by: \.dayOfWeek) + let dayAverages = byDay.mapValues { entries -> Double in + let sum = entries.reduce(0.0) { $0 + Double($1.energy.numericValue) } + return sum / Double(entries.count) + } + let bestDay = dayAverages.max(by: { $0.value < $1.value })?.key + + return WeeklyMoodInsight( + checkInCount: recentCheckIns.count, + averageEnergy: avgEnergy, + peakHours: peakHours, + bestDayOfWeek: bestDay, + distribution: distribution, + insightText: generateInsightText(peakHours: peakHours, avgEnergy: avgEnergy) + ) + } + + /// Generate human-readable insight + private func generateInsightText(peakHours: [Int], avgEnergy: Double) -> String { + guard !peakHours.isEmpty else { return "Keep checking in to see patterns!" } + + let formatter = DateFormatter() + formatter.dateFormat = "ha" + + let peakRange: String + if peakHours.count >= 2 { + let startHour = peakHours.first! + let endHour = peakHours.last! + 1 + let startDate = Calendar.current.date(from: DateComponents(hour: startHour))! + let endDate = Calendar.current.date(from: DateComponents(hour: endHour))! + peakRange = "\(formatter.string(from: startDate))-\(formatter.string(from: endDate))" + } else { + let startDate = Calendar.current.date(from: DateComponents(hour: peakHours[0]))! + peakRange = "around \(formatter.string(from: startDate))" + } + + return "You're most productive \(peakRange)" + } + + // MARK: - Check-In Prompts + + /// Determine if we should prompt for a check-in + func shouldPrompt(for context: CheckInContext) -> Bool { + guard enabled else { return false } + + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + // Limit to max 8 check-ins per day + let todayCheckIns = checkIns.filter { calendar.startOfDay(for: $0.timestamp) == today } + guard todayCheckIns.count < 8 else { return false } + + // Don't prompt more than once per 30 minutes + if let lastCheckIn = checkIns.last { + let timeSince = Date().timeIntervalSince(lastCheckIn.timestamp) + guard timeSince > 1800 else { return false } + } + + return true + } + + // MARK: - Persistence + + private func loadCheckIns() { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([MoodCheckIn].self, from: data) else { return } + checkIns = decoded + } + + private func saveCheckIns() { + if let data = try? JSONEncoder().encode(checkIns) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } +} + +// MARK: - Models + +enum EnergyLevel: String, Codable, CaseIterable, Identifiable { + case exhausted = "exhausted" + case low = "low" + case okay = "okay" + case energized = "energized" + + var id: String { rawValue } + + var emoji: String { + switch self { + case .exhausted: return "😴" + case .low: return "😐" + case .okay: return "😊" + case .energized: return "🔥" + } + } + + var label: String { + switch self { + case .exhausted: return "Exhausted" + case .low: return "Low" + case .okay: return "Good" + case .energized: return "Energized" + } + } + + var numericValue: Int { + switch self { + case .exhausted: return 1 + case .low: return 2 + case .okay: return 3 + case .energized: return 4 + } + } +} + +enum CheckInContext: String, Codable { + case postTimer = "post_timer" + case morning = "morning" + case postLunch = "post_lunch" + case evening = "evening" + case manual = "manual" +} + +struct MoodCheckIn: Codable, Identifiable { + let id: String + let energy: EnergyLevel + let timerId: String? + let timerLabel: String? + let context: CheckInContext + let timestamp: Date + let hourOfDay: Int + let dayOfWeek: Int + + init(energy: EnergyLevel, timerId: String?, timerLabel: String?, context: CheckInContext, timestamp: Date, hourOfDay: Int, dayOfWeek: Int) { + self.id = UUID().uuidString + self.energy = energy + self.timerId = timerId + self.timerLabel = timerLabel + self.context = context + self.timestamp = timestamp + self.hourOfDay = hourOfDay + self.dayOfWeek = dayOfWeek + } +} + +struct WeeklyMoodInsight { + let checkInCount: Int + let averageEnergy: Double + let peakHours: [Int] + let bestDayOfWeek: Int? + let distribution: [EnergyLevel: Int] + let insightText: String + + var averageEnergyLabel: String { + switch averageEnergy { + case 3.5...: return "High" + case 2.5..<3.5: return "Moderate" + case 1.5..<2.5: return "Low" + default: return "Very Low" + } + } +}