feat(water): pomodoro auto-+1 + hourly workday reminder

Two cross-tab integrations from the 2026-05-20 product superpower
review:

1. waterAutoFromPomodoro (default true) — each pomodoro focus phase
   end auto-logs 1 cup. Lazy users get streak data without taps,
   pomodoro break is the natural "go drink water" cue.

2. waterHourlyReminder (default true) — Mon–Fri 9..18 hourly nag if
   cups < goal. Deduped by last-fired-hour so plugin restart in the
   same hour won't double-fire.

UI: WaterView now wraps in ScrollView (toggles push it past 580pt),
adds two togglePill rows below the goalRow. Custom slim-track
indicator since SwiftUI's Toggle styling clashes with the plugin's
dark capsule aesthetic.

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

View File

@ -42,6 +42,9 @@ private enum K {
static let sitAccumActive = "sit.accumActive" // Int seconds, persisted active time static let sitAccumActive = "sit.accumActive" // Int seconds, persisted active time
static let waterHistory = "water.history" // [String: Int] (date cups) static let waterHistory = "water.history" // [String: Int] (date cups)
static let waterGoal = "water.goal" // Int, default 8 static let waterGoal = "water.goal" // Int, default 8
static let waterAutoFromPomo = "water.autoFromPomo" // Bool, default true
static let waterHourlyReminder = "water.hourlyReminder" // Bool, default true
static let lastWaterReminderHr = "water.lastReminderHour" // Int, hour 0-23 last fired (per day)
static let clockoutHHmm = "clockout.hhmm" // String "18:00" static let clockoutHHmm = "clockout.hhmm" // String "18:00"
static let lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day static let lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day
} }
@ -136,6 +139,13 @@ final class WorkerStore: ObservableObject {
/// Water /// Water
@Published var waterCupsToday: Int = 0 @Published var waterCupsToday: Int = 0
@Published var waterGoal: Int = 8 @Published var waterGoal: Int = 8
/// When ON (default), completing a pomodoro focus phase auto-adds
/// 1 cup. Lazy-tracker users get streak-built without manual taps.
@Published var waterAutoFromPomodoro: Bool = true
/// When ON (default), hourly notification fires during 9-18 if today's
/// cups < goal. Dedupe by last-fired-hour so plugin restart doesn't
/// double-fire the same hour.
@Published var waterHourlyReminder: Bool = true
/// Clockout /// Clockout
@Published var clockoutHour: Int = 18 @Published var clockoutHour: Int = 18
@ -295,6 +305,9 @@ final class WorkerStore: ObservableObject {
// 3. Weekend countdown recompute every tick (cheap). // 3. Weekend countdown recompute every tick (cheap).
recomputeWeekend() recomputeWeekend()
// 3.5. Water hourly reminder workday window only.
maybeFireWaterHourlyReminder(now: now)
// 4. Clockout fire celebration on the boundary crossing edge. // 4. Clockout fire celebration on the boundary crossing edge.
let curClockoutRem = clockoutRemainingSec let curClockoutRem = clockoutRemainingSec
if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 { if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 {
@ -311,6 +324,34 @@ final class WorkerStore: ObservableObject {
rolloverIfNeeded() rolloverIfNeeded()
} }
/// Hourly water nag fires once at the top of the hour during
/// workday (9..18) on weekdays if the user's behind their cup goal.
/// Dedupes by storing the last-fired hour; plugin restart inside
/// the same hour won't re-fire.
private func maybeFireWaterHourlyReminder(now: Date) {
guard waterHourlyReminder else { return }
// Weekend = no nag combined with the existing isWeekend gate
// for clockout, this keeps Saturday/Sunday quiet.
let weekday = calendar.component(.weekday, from: now)
guard (2...6).contains(weekday) else { return }
let hour = calendar.component(.hour, from: now)
guard (9...18).contains(hour) else { return }
// Already reached goal no nag.
guard waterCupsToday < waterGoal else { return }
let lastFiredHour = defaults.integer(forKey: K.lastWaterReminderHr)
// Sentinel: 0 means "never fired" first run at any hour >= 9
// will pass the != check. We accept one wasted re-fire on the
// boundary case where last == hour 0 from a fresh install.
guard lastFiredHour != hour else { return }
defaults.set(hour, forKey: K.lastWaterReminderHr)
let remaining = waterGoal - waterCupsToday
WorkerNotificationCenter.shared.notify(
title: "💧 喝水时间到",
body: "今天已喝 \(waterCupsToday) 杯,距 \(waterGoal) 杯目标还差 \(remaining) 杯。"
)
WorkerDebugLog.write("water hourly reminder fired @ \(hour):00, cups \(waterCupsToday)/\(waterGoal)")
}
/// Per-day-deduped celebration trigger. Fires UN notification + /// Per-day-deduped celebration trigger. Fires UN notification +
/// Glass tone × 3 + sets the in-panel banner flag for ~5s. /// Glass tone × 3 + sets the in-panel banner flag for ~5s.
private func triggerClockoutCelebrationIfNeeded() { private func triggerClockoutCelebrationIfNeeded() {
@ -435,6 +476,15 @@ final class WorkerStore: ObservableObject {
pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1) pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1)
defaults.set(pomodoroCycleProgress, forKey: K.pomodoroCycleProg) defaults.set(pomodoroCycleProgress, forKey: K.pomodoroCycleProg)
// Cross-tab boost: a completed focus phase auto-logs a cup
// of water unless the user has flipped the toggle off.
// Rationale: people who do pomodoro typically need a break +
// water anyway couple the data so they don't have to tap.
if waterAutoFromPomodoro {
waterAddCup()
WorkerDebugLog.write("water +1 from pomodoro phase end")
}
// 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
@ -550,6 +600,16 @@ final class WorkerStore: ObservableObject {
defaults.set(v, forKey: K.waterGoal) defaults.set(v, forKey: K.waterGoal)
} }
func waterSetAutoFromPomodoro(_ on: Bool) {
waterAutoFromPomodoro = on
defaults.set(on, forKey: K.waterAutoFromPomo)
}
func waterSetHourlyReminder(_ on: Bool) {
waterHourlyReminder = on
defaults.set(on, forKey: K.waterHourlyReminder)
}
private func persistWaterToday() { private func persistWaterToday() {
var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday
@ -749,6 +809,14 @@ final class WorkerStore: ObservableObject {
waterGoal = storedGoal > 0 ? storedGoal : 8 waterGoal = storedGoal > 0 ? storedGoal : 8
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
// Toggle defaults "explicit-set check" so an installed-with-
// false user doesn't get flipped back to true on update.
waterAutoFromPomodoro = defaults.object(forKey: K.waterAutoFromPomo) != nil
? defaults.bool(forKey: K.waterAutoFromPomo)
: true
waterHourlyReminder = defaults.object(forKey: K.waterHourlyReminder) != nil
? defaults.bool(forKey: K.waterHourlyReminder)
: true
// Clockout // Clockout
let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00" let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00"

View File

@ -11,24 +11,86 @@ struct WaterView: View {
@ObservedObject var store: WorkerStore @ObservedObject var store: WorkerStore
var body: some View { var body: some View {
VStack(spacing: 18) { ScrollView {
statusBadge VStack(spacing: 14) {
statusBadge
cupHero cupHero
controls controls
Divider() Divider()
.background(WorkerTheme.overlay08) .background(WorkerTheme.overlay08)
.padding(.horizontal, 24) .padding(.horizontal, 24)
goalRow goalRow
toggleRows
tipText tipText
Spacer(minLength: 0) Spacer(minLength: 0)
}
.padding(.top, 8)
.padding(.bottom, 8)
} }
.padding(.top, 8) }
// MARK: - Toggle rows (auto-from-pomodoro + hourly reminder)
private var toggleRows: some View {
VStack(spacing: 8) {
togglePill(
icon: "timer",
label: "番茄完成 +1 杯",
isOn: store.waterAutoFromPomodoro,
tint: WorkerTheme.tomato,
action: { store.waterSetAutoFromPomodoro(!store.waterAutoFromPomodoro) }
)
togglePill(
icon: "bell.fill",
label: "整点提醒918 点)",
isOn: store.waterHourlyReminder,
tint: WorkerTheme.water,
action: { store.waterSetHourlyReminder(!store.waterHourlyReminder) }
)
}
.padding(.horizontal, 16)
}
private func togglePill(icon: String, label: String, isOn: Bool, tint: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(isOn ? tint : WorkerTheme.fg55)
.frame(width: 18)
Text(label)
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(isOn ? WorkerTheme.fgPrimary : WorkerTheme.fg70)
Spacer()
// Slim track-style toggle indicator.
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule()
.fill(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08)
.frame(width: 28, height: 16)
Circle()
.fill(isOn ? tint : WorkerTheme.fg55)
.frame(width: 12, height: 12)
.padding(2)
}
}
.padding(.horizontal, 12)
.frame(height: 32)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(WorkerTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08, lineWidth: 0.5)
)
)
}
.buttonStyle(.plain)
} }
// MARK: - Status // MARK: - Status