diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift index 07533d9..ca763cf 100644 --- a/Sources/engine/WorkerStore.swift +++ b/Sources/engine/WorkerStore.swift @@ -27,6 +27,7 @@ import SwiftUI private enum K { static let pomodoroHistory = "pomodoro.history" // [String: Int] (yyyy-MM-dd โ†’ count) + static let pomodoroQualityHist = "pomodoro.qualityHistory" // [String: [Double]] (yyyy-MM-dd โ†’ [quality, ...]) static let pomodoroFocusMin = "pomodoro.focusMin" // Int, default 25 static let pomodoroBreakMin = "pomodoro.breakMin" // Int, default 5 static let pomodoroLongBreakMin = "pomodoro.longBreakMin" // Int, default 15 @@ -105,6 +106,17 @@ final class WorkerStore: ObservableObject { /// break ends, this resets to 0. Dot indicator in PomodoroView reads /// this to draw the "๐Ÿ…๐Ÿ…โšช๏ธโšช๏ธ" cycle position. @Published var pomodoroCycleProgress: Int = 0 + /// Today's average focus-quality score (0..10). 10 = full focus + /// (no >30s idle gap during any focus phase); 0 = idle the whole + /// time. Updated each time a focus phase ends. + @Published var pomodoroQualityTodayAvg: Double = 0 + /// Number of focus phases that contributed to today's avg. + /// Used by PomodoroView to dim the score when n=0. + @Published var pomodoroQualityTodayN: Int = 0 + /// Idle seconds accumulated within the active focus phase. A "second + /// of idleness" = a 1Hz tick where SystemIdle.seconds > 30. Used to + /// compute the focus quality on phase end. + private var currentFocusIdleSec: Int = 0 /// Wallclock target. tick computes remaining = endsAt - now. Storing /// this (instead of decrementing a counter every second) means the @@ -224,6 +236,13 @@ final class WorkerStore: ObservableObject { pomodoroPhase == .focus || pomodoroPhase == .rest { let remaining = max(0, Int(endsAt.timeIntervalSinceNow)) pomodoroRemaining = remaining + // Accumulate idle time during focus phases so we can score + // quality on phase end. Threshold > 30s drops "I'm thinking + // about the code" false positives but catches "I went to + // Slack / opened YouTube" real distractions. + if pomodoroPhase == .focus && SystemIdle.seconds > 30 { + currentFocusIdleSec += 1 + } if remaining <= 0 { pomodoroPhaseEnded() } @@ -485,6 +504,16 @@ final class WorkerStore: ObservableObject { WorkerDebugLog.write("water +1 from pomodoro phase end") } + // Score the just-ended focus phase. focusSec is the total + // wallclock length (pomodoroFocusMin ร— 60); idleSec is what + // tickFire accumulated. quality = 10 ร— (1 โˆ’ idle/total). + let focusSec = max(1, pomodoroFocusMin * 60) + let idleSec = min(currentFocusIdleSec, focusSec) + let quality = 10.0 * (1.0 - Double(idleSec) / Double(focusSec)) + recordPomodoroQuality(quality) + WorkerDebugLog.write("pomodoro focus quality = \(String(format: "%.1f", quality)) (idle \(idleSec)s / \(focusSec)s)") + currentFocusIdleSec = 0 // reset for next focus + // 4th focus in the cycle โ†’ long break; otherwise short. let isLongBreak = pomodoroCycleProgress >= 4 let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin @@ -539,6 +568,21 @@ final class WorkerStore: ObservableObject { defaults.set(dict, forKey: K.pomodoroHistory) } + /// Append `q` to today's quality list and recompute the published + /// average. Stored as `[String: [Double]]` in defaults โ€” keyed by + /// the same yyyy-MM-dd date string as pomodoroHistory so a + /// future "today: 6 focuses, avg 8.4/10" reads both in sync. + private func recordPomodoroQuality(_ q: Double) { + let today = Self.dateKey(Date(), calendar: calendar) + var dict = (defaults.dictionary(forKey: K.pomodoroQualityHist) as? [String: [Double]]) ?? [:] + var list = dict[today] ?? [] + list.append(q) + dict[today] = list + defaults.set(dict, forKey: K.pomodoroQualityHist) + pomodoroQualityTodayN = list.count + pomodoroQualityTodayAvg = list.reduce(0, +) / Double(list.count) + } + /// Persist the runtime fields that change with start/pause/reset/phase /// transitions. Keeps a relaunch consistent with what the user saw. private func persistPomodoroRuntime() { @@ -712,6 +756,11 @@ final class WorkerStore: ObservableObject { pomodoroTodayCount = pomDict[today] ?? 0 let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] waterCupsToday = waterDict[today] ?? 0 + // Quality history resets too. + let qDict = (defaults.dictionary(forKey: K.pomodoroQualityHist) as? [String: [Double]]) ?? [:] + let todayQs = qDict[today] ?? [] + pomodoroQualityTodayN = todayQs.count + pomodoroQualityTodayAvg = todayQs.isEmpty ? 0 : todayQs.reduce(0, +) / Double(todayQs.count) // Sitting through midnight is weird โ€” start a fresh day's // active-time count so the badge doesn't show "ๅทฒๅ 1380 ๅˆ†". sitAccumActiveSec = 0 @@ -742,6 +791,12 @@ final class WorkerStore: ObservableObject { pomodoroTodayCount = pomDict[today] ?? 0 lastSeenDay = today + // Quality history for today (used for the "ไปŠๆ—ฅๅ‡ๅˆ†" stat card). + let qDict = (defaults.dictionary(forKey: K.pomodoroQualityHist) as? [String: [Double]]) ?? [:] + let todayQs = qDict[today] ?? [] + pomodoroQualityTodayN = todayQs.count + pomodoroQualityTodayAvg = todayQs.isEmpty ? 0 : todayQs.reduce(0, +) / Double(todayQs.count) + // Pomodoro โ€” runtime resume. let pausedRaw = defaults.string(forKey: K.pomodoroPausedPhase) ?? "focus" pausedPhase = PomodoroPhase(rawValue: pausedRaw) ?? .focus diff --git a/Sources/ui/PomodoroView.swift b/Sources/ui/PomodoroView.swift index 3c0cd8e..962ca01 100644 --- a/Sources/ui/PomodoroView.swift +++ b/Sources/ui/PomodoroView.swift @@ -201,7 +201,7 @@ struct PomodoroView: View { // MARK: - Stats private var statsRow: some View { - HStack(spacing: 10) { + HStack(spacing: 8) { statCard( title: "ไปŠๆ—ฅ็•ช่Œ„", value: "\(store.pomodoroTodayCount)", @@ -212,8 +212,30 @@ struct PomodoroView: View { value: "\(store.pomodoroTodayCount * store.pomodoroFocusMin)ๅˆ†", accent: WorkerTheme.lime ) + // Quality score: dimmed (em-dash) when n=0, otherwise the + // running average across today's focus phases. + // 8 / 10 = "1500s focus with โ‰ค300s idle" โ€” solid focus + // 5 / 10 = "half the time I was elsewhere" + // <3 = "you were not really pomodoro-ing" + statCard( + title: "ไปŠๆ—ฅๅ‡ๅˆ†", + value: store.pomodoroQualityTodayN == 0 + ? "โ€”" + : String(format: "%.1f", store.pomodoroQualityTodayAvg), + accent: store.pomodoroQualityTodayN == 0 + ? WorkerTheme.fg40 + : qualityAccent(store.pomodoroQualityTodayAvg) + ) } - .padding(.horizontal, 16) + .padding(.horizontal, 12) + } + + /// Score โ†’ colour: high = lime, mid = tomato, low = alertRed. + /// Visual sanity-check on glance โ€” green is good. + private func qualityAccent(_ score: Double) -> Color { + if score >= 8 { return WorkerTheme.lime } + if score >= 5 { return WorkerTheme.tomato } + return WorkerTheme.alertRed } private func statCard(title: String, value: String, accent: Color) -> some View {