feat(wellness): add sleep manager (HealthKit, bedtime routine, smart alarm) + mood/energy check-in with weekly insights

This commit is contained in:
saravanakumardb1 2026-02-27 22:57:47 -08:00
parent 639d606233
commit 3df8ac597b

View File

@ -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"
}
}
}