feat(wellness): add sleep manager (HealthKit, bedtime routine, smart alarm) + mood/energy check-in with weekly insights
This commit is contained in:
parent
639d606233
commit
3df8ac597b
237
ios/ChronoMind/Shared/Wellness/MoodCheckInManager.swift
Normal file
237
ios/ChronoMind/Shared/Wellness/MoodCheckInManager.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user