feat(pomodoro): focus quality score (idle gap tracking)

New cross-cutting metric: each focus phase accumulates SystemIdle
seconds > 30s, then on phase end computes quality = 10 × (1 -
idle/total). Today's average is published as pomodoroQualityTodayAvg
and rendered as a third stat card in PomodoroView.

Rationale: pomodoro phases counted but not graded conflate "actually
focused 25min" with "started the timer, opened YouTube, came back."
The idle accumulator catches Slack/IDE-switch/AFK distractions.

Threshold > 30s drops "I'm thinking about the algorithm" false
positives (most code-think pauses last <30s, the user still moves
the mouse / scrolls / glances).

Score color: ≥8 lime, ≥5 tomato, <5 alertRed. Em-dash when n=0.

Persisted as [String: [Double]] (date → list of quality scores) so
day rollover preserves history for future trend chart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-05-20 09:52:27 +08:00
parent 4b2ac2fc59
commit 2aa68c2427
2 changed files with 79 additions and 2 deletions

View File

@ -27,6 +27,7 @@ import SwiftUI
private enum K { private enum K {
static let pomodoroHistory = "pomodoro.history" // [String: Int] (yyyy-MM-dd count) 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 pomodoroFocusMin = "pomodoro.focusMin" // Int, default 25
static let pomodoroBreakMin = "pomodoro.breakMin" // Int, default 5 static let pomodoroBreakMin = "pomodoro.breakMin" // Int, default 5
static let pomodoroLongBreakMin = "pomodoro.longBreakMin" // Int, default 15 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 /// break ends, this resets to 0. Dot indicator in PomodoroView reads
/// this to draw the "🍅🍅" cycle position. /// this to draw the "🍅🍅" cycle position.
@Published var pomodoroCycleProgress: Int = 0 @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 /// Wallclock target. tick computes remaining = endsAt - now. Storing
/// this (instead of decrementing a counter every second) means the /// this (instead of decrementing a counter every second) means the
@ -224,6 +236,13 @@ final class WorkerStore: ObservableObject {
pomodoroPhase == .focus || pomodoroPhase == .rest { pomodoroPhase == .focus || pomodoroPhase == .rest {
let remaining = max(0, Int(endsAt.timeIntervalSinceNow)) let remaining = max(0, Int(endsAt.timeIntervalSinceNow))
pomodoroRemaining = remaining 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 { if remaining <= 0 {
pomodoroPhaseEnded() pomodoroPhaseEnded()
} }
@ -485,6 +504,16 @@ final class WorkerStore: ObservableObject {
WorkerDebugLog.write("water +1 from pomodoro phase end") 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. // 4th focus in the cycle long break; otherwise short.
let isLongBreak = pomodoroCycleProgress >= 4 let isLongBreak = pomodoroCycleProgress >= 4
let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin
@ -539,6 +568,21 @@ final class WorkerStore: ObservableObject {
defaults.set(dict, forKey: K.pomodoroHistory) 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 /// Persist the runtime fields that change with start/pause/reset/phase
/// transitions. Keeps a relaunch consistent with what the user saw. /// transitions. Keeps a relaunch consistent with what the user saw.
private func persistPomodoroRuntime() { private func persistPomodoroRuntime() {
@ -712,6 +756,11 @@ final class WorkerStore: ObservableObject {
pomodoroTodayCount = pomDict[today] ?? 0 pomodoroTodayCount = pomDict[today] ?? 0
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
waterCupsToday = waterDict[today] ?? 0 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 // Sitting through midnight is weird start a fresh day's
// active-time count so the badge doesn't show " 1380 ". // active-time count so the badge doesn't show " 1380 ".
sitAccumActiveSec = 0 sitAccumActiveSec = 0
@ -742,6 +791,12 @@ final class WorkerStore: ObservableObject {
pomodoroTodayCount = pomDict[today] ?? 0 pomodoroTodayCount = pomDict[today] ?? 0
lastSeenDay = today 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. // Pomodoro runtime resume.
let pausedRaw = defaults.string(forKey: K.pomodoroPausedPhase) ?? "focus" let pausedRaw = defaults.string(forKey: K.pomodoroPausedPhase) ?? "focus"
pausedPhase = PomodoroPhase(rawValue: pausedRaw) ?? .focus pausedPhase = PomodoroPhase(rawValue: pausedRaw) ?? .focus

View File

@ -201,7 +201,7 @@ struct PomodoroView: View {
// MARK: - Stats // MARK: - Stats
private var statsRow: some View { private var statsRow: some View {
HStack(spacing: 10) { HStack(spacing: 8) {
statCard( statCard(
title: "今日番茄", title: "今日番茄",
value: "\(store.pomodoroTodayCount)", value: "\(store.pomodoroTodayCount)",
@ -212,8 +212,30 @@ struct PomodoroView: View {
value: "\(store.pomodoroTodayCount * store.pomodoroFocusMin)", value: "\(store.pomodoroTodayCount * store.pomodoroFocusMin)",
accent: WorkerTheme.lime 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 { private func statCard(title: String, value: String, accent: Color) -> some View {