238 lines
7.5 KiB
Swift
238 lines
7.5 KiB
Swift
// ── 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"
|
|
}
|
|
}
|
|
}
|