mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
Compare commits
No commits in common. "main" and "v0.2.0" have entirely different histories.
@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.0</string>
|
<string>0.2.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>3</string>
|
<string>2</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>WorkerPlugin.WorkerPlugin</string>
|
<string>WorkerPlugin.WorkerPlugin</string>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@ -13,7 +13,7 @@ final class WorkerPlugin: NSObject, MioPlugin {
|
|||||||
var id: String { "worker" }
|
var id: String { "worker" }
|
||||||
var name: String { "摸鱼侠" }
|
var name: String { "摸鱼侠" }
|
||||||
var icon: String { "fish.fill" }
|
var icon: String { "fish.fill" }
|
||||||
var version: String { "0.3.0" }
|
var version: String { "0.2.0" }
|
||||||
|
|
||||||
func activate() {
|
func activate() {
|
||||||
WorkerDebugLog.write("plugin activate")
|
WorkerDebugLog.write("plugin activate")
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
//
|
|
||||||
// Holidays.swift
|
|
||||||
// 摸鱼侠 plugin v0.3.0
|
|
||||||
//
|
|
||||||
// China State Council statutory holidays for 2026. Calibrated to the
|
|
||||||
// 公历日期 + 农历对应日 + typical 调休 pattern; **the official 国务院
|
|
||||||
// 办公厅 announcement (usually released Nov of prior year)** is the
|
|
||||||
// ultimate source of truth. If a date here disagrees with 国务院
|
|
||||||
// notice when published, edit this file and re-release.
|
|
||||||
//
|
|
||||||
// Reference dates used (公历 yyyy-MM-dd → 农历):
|
|
||||||
// 2026-02-17 → 农历 正月 初一 (春节)
|
|
||||||
// 2026-04-05 → 清明 (公历固定)
|
|
||||||
// 2026-06-19 → 农历 五月 初五 (端午)
|
|
||||||
// 2026-09-25 → 农历 八月 十五 (中秋)
|
|
||||||
//
|
|
||||||
// In years where 中秋 falls within 6 days of 国庆, the State Council
|
|
||||||
// typically merges them into a single 8-10 day window. 2026 has
|
|
||||||
// 中秋 09-25 (Fri) and 国庆 10-01 (Thu) → window 09-25 ~ 10-04 is the
|
|
||||||
// most likely pattern (with 10-10 Saturday or earlier 09-20 Sunday
|
|
||||||
// for makeup work day).
|
|
||||||
//
|
|
||||||
// Data structure is a flat list rather than a JSON resource so the
|
|
||||||
// plugin bundle stays single-binary and dependency-free.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Holiday {
|
|
||||||
/// "元旦" / "春节" / "国庆" / etc.
|
|
||||||
let name: String
|
|
||||||
/// First day of the holiday window (yyyy-MM-dd, Asia/Shanghai).
|
|
||||||
let startDate: String
|
|
||||||
/// Total consecutive days off, including the trigger day.
|
|
||||||
let days: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolved upcoming holiday with the days remaining and start Date.
|
|
||||||
struct UpcomingHoliday {
|
|
||||||
let name: String
|
|
||||||
let startDate: Date
|
|
||||||
let days: Int
|
|
||||||
/// Days from now to startDate. 0 = today is day 1 of the holiday.
|
|
||||||
let daysUntil: Int
|
|
||||||
/// True when "now" falls inside [startDate, startDate+days).
|
|
||||||
let isOngoing: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum HolidayDatabase {
|
|
||||||
/// 2026 法定假期 — best estimate per typical State Council调休 pattern.
|
|
||||||
/// VERIFY against the official 国务院办公厅 公告 when published.
|
|
||||||
///
|
|
||||||
/// 春节 starts on 除夕 (the day before 初一) per 2024 policy update.
|
|
||||||
/// 中秋 + 国庆 merged because 2026 中秋 09-25 falls 6 days before
|
|
||||||
/// 国庆 10-01 — same pattern as 2017 / 2020 when they were close.
|
|
||||||
static let entries2026: [Holiday] = [
|
|
||||||
// 元旦 周四 — 单日不调休(2024 政策起元旦只放 1 天)
|
|
||||||
Holiday(name: "元旦", startDate: "2026-01-01", days: 1),
|
|
||||||
// 春节 02-16 除夕 ~ 02-22 初七,7 天。调休 02-14 周六 + 02-28 周六上班
|
|
||||||
Holiday(name: "春节", startDate: "2026-02-16", days: 7),
|
|
||||||
// 清明 04-05 周日,调休 04-04 ~ 04-06 共 3 天
|
|
||||||
Holiday(name: "清明", startDate: "2026-04-04", days: 3),
|
|
||||||
// 五一 05-01 周五 ~ 05-05 周二,5 天。调休 04-26 周日上班
|
|
||||||
Holiday(name: "劳动节", startDate: "2026-05-01", days: 5),
|
|
||||||
// 端午 06-19 周五 ~ 06-21 周日,3 天(自然连周末,无调休)
|
|
||||||
Holiday(name: "端午", startDate: "2026-06-19", days: 3),
|
|
||||||
// 中秋 + 国庆 合并 09-25 周五 ~ 10-04 周日,10 天
|
|
||||||
// 调休 09-19 / 10-10 周六上班
|
|
||||||
Holiday(name: "中秋·国庆", startDate: "2026-09-25", days: 10),
|
|
||||||
// 跨年 roll-over — 元旦 2027 估计 01-01 周五 ~ 01-03 周日,3 天
|
|
||||||
Holiday(name: "元旦", startDate: "2027-01-01", days: 3),
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Returns the soonest upcoming or currently-ongoing holiday.
|
|
||||||
/// `now` is injectable for testability.
|
|
||||||
static func upcoming(from now: Date, timeZone: TimeZone) -> UpcomingHoliday? {
|
|
||||||
let fmt = DateFormatter()
|
|
||||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
fmt.timeZone = timeZone
|
|
||||||
fmt.dateFormat = "yyyy-MM-dd"
|
|
||||||
var cal = Calendar(identifier: .gregorian)
|
|
||||||
cal.timeZone = timeZone
|
|
||||||
for h in entries2026 {
|
|
||||||
guard let start = fmt.date(from: h.startDate) else { continue }
|
|
||||||
guard let end = cal.date(byAdding: .day, value: h.days, to: start) else { continue }
|
|
||||||
if now >= start && now < end {
|
|
||||||
// In the holiday window already.
|
|
||||||
return UpcomingHoliday(
|
|
||||||
name: h.name, startDate: start,
|
|
||||||
days: h.days, daysUntil: 0, isOngoing: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if start > now {
|
|
||||||
// Floor-divide of seconds to days for an integer answer
|
|
||||||
// independent of locale calendar shenanigans.
|
|
||||||
let secs = start.timeIntervalSince(now)
|
|
||||||
let daysUntil = Int(ceil(secs / 86400.0))
|
|
||||||
return UpcomingHoliday(
|
|
||||||
name: h.name, startDate: start,
|
|
||||||
days: h.days, daysUntil: daysUntil, isOngoing: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -31,35 +31,24 @@ final class WorkerNotificationCenter {
|
|||||||
let status = settings.authorizationStatus
|
let status = settings.authorizationStatus
|
||||||
switch status {
|
switch status {
|
||||||
case .authorized, .provisional, .ephemeral:
|
case .authorized, .provisional, .ephemeral:
|
||||||
Task { @MainActor in Self.applyAuthorized(true) }
|
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = true }
|
||||||
case .denied:
|
case .denied:
|
||||||
Task { @MainActor in Self.applyAuthorized(false) }
|
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false }
|
||||||
WorkerDebugLog.write("notifications denied — falling back to in-panel dot")
|
WorkerDebugLog.write("notifications denied — falling back to in-panel dot")
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
WorkerDebugLog.write("notif auth error: \(error)")
|
WorkerDebugLog.write("notif auth error: \(error)")
|
||||||
}
|
}
|
||||||
Task { @MainActor in Self.applyAuthorized(granted) }
|
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = granted }
|
||||||
WorkerDebugLog.write("notif auth granted=\(granted)")
|
WorkerDebugLog.write("notif auth granted=\(granted)")
|
||||||
}
|
}
|
||||||
@unknown default:
|
@unknown default:
|
||||||
Task { @MainActor in Self.applyAuthorized(false) }
|
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// P0 fix (2026-05-19 review): prior code set
|
|
||||||
/// `WorkerNotificationCenter.shared.isAuthorized` but never
|
|
||||||
/// propagated to `WorkerStore.notificationsAuthorized`. Result: the
|
|
||||||
/// top-bar notif status dot stayed dim even after the user authorized.
|
|
||||||
/// Always write both so observers see consistent state.
|
|
||||||
@MainActor
|
|
||||||
private static func applyAuthorized(_ value: Bool) {
|
|
||||||
WorkerNotificationCenter.shared.isAuthorized = value
|
|
||||||
WorkerStore.shared.notificationsAuthorized = value
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire-and-forget local notification. Returns true if scheduled
|
/// Fire-and-forget local notification. Returns true if scheduled
|
||||||
/// (best effort — auth status may flip between scheduling and firing).
|
/// (best effort — auth status may flip between scheduling and firing).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@ -1,108 +0,0 @@
|
|||||||
//
|
|
||||||
// SoundPlayer.swift
|
|
||||||
// 摸鱼侠 plugin v0.2.1
|
|
||||||
//
|
|
||||||
// Sit-reminder alert tone. The bundled UN notification sound is a
|
|
||||||
// ~1s ding that's easy to miss in a meeting — for sit alerts we want
|
|
||||||
// a 15s nag that makes you actually stand up.
|
|
||||||
//
|
|
||||||
// Implementation: repeat the macOS system "Morse" sound (0.7s) once
|
|
||||||
// per second for 15 ticks. Morse is the most distinctive notification
|
|
||||||
// sound shipped with macOS — staccato 3-tone "S O S" pattern that
|
|
||||||
// pierces background noise better than Glass/Hero/Ping/Funk/etc.
|
|
||||||
//
|
|
||||||
// Plays via NSSound (not UNNotificationContent.sound) because UN
|
|
||||||
// sounds are limited to ~30s but get truncated to ~5s on macOS, and
|
|
||||||
// attach a single tone to a single notification — no way to fire a
|
|
||||||
// repeated sequence from UN.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class SoundPlayer {
|
|
||||||
static let shared = SoundPlayer()
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
/// Sit-alert sound. Picked Submarine (1.5s sonar ping) over the
|
|
||||||
/// original Morse — the sonar's longer sustain + lower pitch
|
|
||||||
/// pierces noise-cancelling headphones and meeting audio better,
|
|
||||||
/// while the 15-second 1Hz cadence still drives the urgency.
|
|
||||||
/// To switch back: change to "Morse" / "Tink" / "Ping" / etc.
|
|
||||||
/// All built-in: /System/Library/Sounds/*.aiff
|
|
||||||
private let sitAlertURL = URL(fileURLWithPath: "/System/Library/Sounds/Submarine.aiff")
|
|
||||||
|
|
||||||
private var timer: Timer?
|
|
||||||
private var remaining: Int = 0
|
|
||||||
private var currentSound: NSSound?
|
|
||||||
|
|
||||||
/// Repeated sit alert: play `count` times at 1Hz. Each play is a
|
|
||||||
/// fresh NSSound so previous-in-flight playback doesn't get stomped
|
|
||||||
/// (NSSound.play() is non-blocking; Submarine's 1.5s overlap with
|
|
||||||
/// the next-second trigger creates a slightly layered effect —
|
|
||||||
/// acoustically more present than crisp single beeps).
|
|
||||||
///
|
|
||||||
/// Calling while a previous alert is still running cancels the old
|
|
||||||
/// one and restarts from `count`. This keeps "user sat back down +
|
|
||||||
/// trigger fired again 45min later" clean.
|
|
||||||
///
|
|
||||||
/// Name retained for source compat (callsite is single, low cost).
|
|
||||||
func playMorseSitAlert(count: Int = 15) {
|
|
||||||
stop()
|
|
||||||
remaining = count
|
|
||||||
playOnce() // fire immediately, then 1Hz repeat
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in self?.playOnce() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clockout celebration tone — Hero (1.1s heroic fanfare),
|
|
||||||
/// played 3 times with 0.6s gap. Picked over the original Glass
|
|
||||||
/// for more triumph energy on the "下班" moment. Glass was
|
|
||||||
/// "✨ achievement"; Hero is "🎺 victory" — appropriate scale.
|
|
||||||
/// To switch: change to "Funk" / "Sosumi" / "Glass" / etc.
|
|
||||||
func playClockoutCelebration() {
|
|
||||||
stop()
|
|
||||||
let celebrationURL = URL(fileURLWithPath: "/System/Library/Sounds/Hero.aiff")
|
|
||||||
// 3 chimes at 0s, 0.6s, 1.2s — full shot of joy, not a nag.
|
|
||||||
// Schedule with DispatchQueue async since this is a fixed-shot
|
|
||||||
// pattern, no need for a Timer loop.
|
|
||||||
for delay in [0.0, 0.6, 1.2] {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
||||||
guard let _ = self else { return }
|
|
||||||
if let s = NSSound(contentsOf: celebrationURL, byReference: true) {
|
|
||||||
s.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel any in-flight alert. Called by WorkerStore.sitStop() so
|
|
||||||
/// "user stopped sit monitoring" silences the nag immediately.
|
|
||||||
func stop() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
remaining = 0
|
|
||||||
currentSound?.stop()
|
|
||||||
currentSound = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func playOnce() {
|
|
||||||
guard remaining > 0 else {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remaining -= 1
|
|
||||||
// byReference: true — load lazily from the system path each call.
|
|
||||||
// The cost is negligible (< 1ms) and avoids keeping a 200KB AIFF
|
|
||||||
// resident across the 45min idle stretch between alerts.
|
|
||||||
if let sound = NSSound(contentsOf: sitAlertURL, byReference: true) {
|
|
||||||
sound.play()
|
|
||||||
currentSound = sound
|
|
||||||
} else {
|
|
||||||
WorkerDebugLog.write("SoundPlayer: failed to load \(sitAlertURL.path)")
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -27,7 +27,6 @@ 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
|
||||||
@ -43,11 +42,7 @@ 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" // String "yyyy-MM-dd HH" — last hour that fired, scoped 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let suiteName = "com.mioisland.plugin.worker"
|
private let suiteName = "com.mioisland.plugin.worker"
|
||||||
@ -106,17 +101,6 @@ 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
|
||||||
@ -151,26 +135,10 @@ 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
|
||||||
@Published var clockoutMinute: Int = 0
|
@Published var clockoutMinute: Int = 0
|
||||||
/// Brief celebratory flag — true for ~5s after crossing the clockout
|
|
||||||
/// boundary. ClockoutView reads this to overlay a banner. Reset by
|
|
||||||
/// the tick after the fade window.
|
|
||||||
@Published var clockoutCelebrationActive: Bool = false
|
|
||||||
/// Wallclock at which celebration should clear. Tick monitors this.
|
|
||||||
private var clockoutCelebrationEndsAt: Date? = nil
|
|
||||||
/// Previous tick's clockout remaining seconds. Used to detect the
|
|
||||||
/// >0 → 0 crossing edge.
|
|
||||||
private var prevClockoutRemSec: Int? = nil
|
|
||||||
|
|
||||||
/// Weekend (derived)
|
/// Weekend (derived)
|
||||||
@Published var weekendDays: Int = 0
|
@Published var weekendDays: Int = 0
|
||||||
@ -236,13 +204,6 @@ 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()
|
||||||
}
|
}
|
||||||
@ -284,31 +245,19 @@ final class WorkerStore: ObservableObject {
|
|||||||
|
|
||||||
sitElapsedSec = sitAccumActiveSec
|
sitElapsedSec = sitAccumActiveSec
|
||||||
|
|
||||||
// Threshold notification — fire once, then reset the
|
// Threshold notification, deduped by triggerSec.
|
||||||
// accumulator so the next triggerSec of sitting kicks a
|
|
||||||
// FRESH cycle. The prior dedupe-by-lastFired path kept the
|
|
||||||
// counter monotonically growing, so the panel showed "已坐
|
|
||||||
// 90 分钟" / "已坐 135 分钟" instead of restarting each
|
|
||||||
// cycle. Reset is the simpler dedupe: physically can't
|
|
||||||
// re-fire until the user accumulates another triggerSec.
|
|
||||||
let triggerSec = sitTriggerMin * 60
|
let triggerSec = sitTriggerMin * 60
|
||||||
if triggerSec > 0 && sitElapsedSec >= triggerSec {
|
if triggerSec > 0 && sitElapsedSec >= triggerSec {
|
||||||
|
let lastFired = defaults.double(forKey: K.sitLastNotified)
|
||||||
|
let nowTs = Date().timeIntervalSince1970
|
||||||
|
if nowTs - lastFired >= Double(triggerSec) {
|
||||||
|
defaults.set(nowTs, forKey: K.sitLastNotified)
|
||||||
WorkerNotificationCenter.shared.notify(
|
WorkerNotificationCenter.shared.notify(
|
||||||
title: "该起来动一下了",
|
title: "该起来动一下了",
|
||||||
body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。"
|
body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。"
|
||||||
)
|
)
|
||||||
// 1Hz × 15 ticks of system Morse tone — the UN
|
WorkerDebugLog.write("sit threshold notification fired (\(sitElapsedSec)s)")
|
||||||
// notification ding is too easy to miss in a meeting,
|
}
|
||||||
// so the sit alert gets a louder, longer signal.
|
|
||||||
SoundPlayer.shared.playMorseSitAlert(count: 15)
|
|
||||||
WorkerDebugLog.write("sit threshold fired @ \(sitElapsedSec)s — resetting accumulator")
|
|
||||||
|
|
||||||
// Reset for next cycle. Update lastFired for telemetry
|
|
||||||
// even though it's no longer the dedupe gate.
|
|
||||||
sitAccumActiveSec = 0
|
|
||||||
sitElapsedSec = 0
|
|
||||||
defaults.set(0, forKey: K.sitAccumActive)
|
|
||||||
defaults.set(Date().timeIntervalSince1970, forKey: K.sitLastNotified)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist accumulator once every N ticks (cheap & resilient).
|
// Persist accumulator once every N ticks (cheap & resilient).
|
||||||
@ -324,105 +273,12 @@ 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.
|
// 4. Detect day rollover for pomodoro / water / sit counters.
|
||||||
maybeFireWaterHourlyReminder(now: now)
|
|
||||||
|
|
||||||
// 4. Clockout — fire celebration on the boundary crossing edge.
|
|
||||||
let curClockoutRem = clockoutRemainingSec
|
|
||||||
if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 {
|
|
||||||
triggerClockoutCelebrationIfNeeded()
|
|
||||||
}
|
|
||||||
prevClockoutRemSec = curClockoutRem
|
|
||||||
// Clear the celebration flag once the fade window elapses.
|
|
||||||
if let endsAt = clockoutCelebrationEndsAt, Date() >= endsAt {
|
|
||||||
clockoutCelebrationActive = false
|
|
||||||
clockoutCelebrationEndsAt = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Detect day rollover for pomodoro / water / sit counters.
|
|
||||||
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 }
|
|
||||||
// Scope dedupe by date+hour so yesterday's 17:00 doesn't block
|
|
||||||
// today's 17:00. Prior int-only key blocked the same hour
|
|
||||||
// forever within calendar 24h cycles.
|
|
||||||
let key = "\(Self.dateKey(now, calendar: calendar)) \(String(format: "%02d", hour))"
|
|
||||||
let lastFiredKey = defaults.string(forKey: K.lastWaterReminderHr) ?? ""
|
|
||||||
guard lastFiredKey != key else { return }
|
|
||||||
defaults.set(key, 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 +
|
|
||||||
/// Glass tone × 3 + sets the in-panel banner flag for ~5s.
|
|
||||||
private func triggerClockoutCelebrationIfNeeded() {
|
|
||||||
let today = Self.dateKey(Date(), calendar: calendar)
|
|
||||||
let lastCeleb = defaults.string(forKey: K.lastClockoutCelebDate) ?? ""
|
|
||||||
guard lastCeleb != today else {
|
|
||||||
WorkerDebugLog.write("clockout boundary @ \(today) but already celebrated, skip")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaults.set(today, forKey: K.lastClockoutCelebDate)
|
|
||||||
|
|
||||||
WorkerDebugLog.write("clockout celebration fired for \(today)")
|
|
||||||
WorkerNotificationCenter.shared.notify(
|
|
||||||
title: "今日打卡下班 🎉",
|
|
||||||
body: "辛苦了,到点了,关 IDE 下班!"
|
|
||||||
)
|
|
||||||
SoundPlayer.shared.playClockoutCelebration()
|
|
||||||
clockoutCelebrationActive = true
|
|
||||||
clockoutCelebrationEndsAt = Date().addingTimeInterval(5.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pomodoro
|
// MARK: - Pomodoro
|
||||||
|
|
||||||
/// Total duration (seconds) of the active phase. Used as denominator
|
|
||||||
/// for the progress ring fraction in PomodoroView.
|
|
||||||
///
|
|
||||||
/// P0 fix (2026-05-19 review): the ring previously used
|
|
||||||
/// `pomodoroFocusMin * 60` as denominator for the paused state,
|
|
||||||
/// which is wrong when the user paused mid-break (denominator was
|
|
||||||
/// 25min while remaining was a break's 5min worth → ring read 80%
|
|
||||||
/// done when it was actually ~40%). Now `paused` looks at the
|
|
||||||
/// underlying `pausedPhase` so break/long-break paused renders right.
|
|
||||||
var pomodoroPhaseTotalSec: Int {
|
|
||||||
let isLongBreak = pomodoroCycleProgress >= 4
|
|
||||||
switch pomodoroPhase {
|
|
||||||
case .focus:
|
|
||||||
return pomodoroFocusMin * 60
|
|
||||||
case .rest:
|
|
||||||
return (isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin) * 60
|
|
||||||
case .paused:
|
|
||||||
switch pausedPhase {
|
|
||||||
case .rest:
|
|
||||||
return (isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin) * 60
|
|
||||||
default:
|
|
||||||
return pomodoroFocusMin * 60
|
|
||||||
}
|
|
||||||
case .idle:
|
|
||||||
return pomodoroFocusMin * 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pomodoroStart() {
|
func pomodoroStart() {
|
||||||
if pomodoroPhase == .paused {
|
if pomodoroPhase == .paused {
|
||||||
pomodoroPhase = pausedPhase
|
pomodoroPhase = pausedPhase
|
||||||
@ -496,25 +352,6 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
@ -569,21 +406,6 @@ 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() {
|
||||||
@ -613,9 +435,6 @@ final class WorkerStore: ObservableObject {
|
|||||||
defaults.set(false, forKey: K.sitEnabled)
|
defaults.set(false, forKey: K.sitEnabled)
|
||||||
defaults.set(0, forKey: K.sitAccumActive)
|
defaults.set(0, forKey: K.sitAccumActive)
|
||||||
defaults.set(0.0, forKey: K.sitLastNotified)
|
defaults.set(0.0, forKey: K.sitLastNotified)
|
||||||
// Silence any in-flight Morse alert from a prior trigger so the
|
|
||||||
// user stopping monitoring also stops the nag immediately.
|
|
||||||
SoundPlayer.shared.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sitSetTrigger(_ min: Int) {
|
func sitSetTrigger(_ min: Int) {
|
||||||
@ -645,16 +464,6 @@ 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
|
||||||
@ -671,39 +480,18 @@ final class WorkerStore: ObservableObject {
|
|||||||
defaults.set(String(format: "%02d:%02d", h, m), forKey: K.clockoutHHmm)
|
defaults.set(String(format: "%02d:%02d", h, m), forKey: K.clockoutHHmm)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seconds remaining until **today's** clockout. Returns 0 when
|
/// Seconds remaining until today's clockout. If it's already past,
|
||||||
/// already past — the next day rolls automatically when calendar's
|
/// returns the seconds until tomorrow's clockout (rolls over).
|
||||||
/// dateComponents(.year,.month,.day, from: now) picks up the new
|
|
||||||
/// date after midnight.
|
|
||||||
///
|
|
||||||
/// Prior version rolled forward to tomorrow on `target <= now`, so
|
|
||||||
/// the moment clockout was hit the counter jumped 1s → 86399s with
|
|
||||||
/// no observable "zero" frame — celebrating the "下班" moment was
|
|
||||||
/// impossible. Now: hit zero, hold zero until midnight, restart at
|
|
||||||
/// new day's wall-time countdown.
|
|
||||||
/// True iff today is Saturday or Sunday (local time). Used by
|
|
||||||
/// ClockoutView to switch to a "今天不上班" mode without forcing
|
|
||||||
/// the user to fiddle with their clockout time on the weekend.
|
|
||||||
/// Next statutory China holiday, or nil if none configured.
|
|
||||||
/// Backed by `HolidayDatabase`. WeekendView uses this to render
|
|
||||||
/// the "距下个法定假 N 天" card.
|
|
||||||
var nextHoliday: UpcomingHoliday? {
|
|
||||||
HolidayDatabase.upcoming(from: Date(), timeZone: calendar.timeZone)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isWeekend: Bool {
|
|
||||||
let weekday = calendar.component(.weekday, from: Date())
|
|
||||||
return weekday == 1 || weekday == 7 // Sun = 1, Sat = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
var clockoutRemainingSec: Int {
|
var clockoutRemainingSec: Int {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
var comps = calendar.dateComponents([.year, .month, .day], from: now)
|
var comps = calendar.dateComponents([.year, .month, .day], from: now)
|
||||||
comps.hour = clockoutHour
|
comps.hour = clockoutHour
|
||||||
comps.minute = clockoutMinute
|
comps.minute = clockoutMinute
|
||||||
comps.second = 0
|
comps.second = 0
|
||||||
guard let target = calendar.date(from: comps) else { return 0 }
|
var target = calendar.date(from: comps) ?? now
|
||||||
if target <= now { return 0 }
|
if target <= now {
|
||||||
|
target = calendar.date(byAdding: .day, value: 1, to: target) ?? target
|
||||||
|
}
|
||||||
return max(0, Int(target.timeIntervalSince(now)))
|
return max(0, Int(target.timeIntervalSince(now)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -764,11 +552,6 @@ 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
|
||||||
@ -799,12 +582,6 @@ 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
|
||||||
@ -872,14 +649,6 @@ 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"
|
||||||
|
|||||||
@ -14,65 +14,26 @@ struct ClockoutView: View {
|
|||||||
@State private var editMinute: Int = 0
|
@State private var editMinute: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
if store.isWeekend {
|
|
||||||
weekendHero
|
|
||||||
weekendTipText
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
} else {
|
|
||||||
statusBadge
|
statusBadge
|
||||||
|
|
||||||
countdownDisplay
|
countdownDisplay
|
||||||
|
|
||||||
progressBar
|
progressBar
|
||||||
|
|
||||||
controls
|
controls
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.background(WorkerTheme.overlay08)
|
.background(WorkerTheme.overlay08)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
timeRow
|
timeRow
|
||||||
|
|
||||||
tipText
|
tipText
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Celebration banner — overlays the panel for ~5s after the
|
|
||||||
// user crosses today's clockout boundary. Subtle gold→lime
|
|
||||||
// shimmer so it pops without being kindergarten-style confetti.
|
|
||||||
if store.clockoutCelebrationActive {
|
|
||||||
celebrationBanner
|
|
||||||
.transition(
|
|
||||||
.asymmetric(
|
|
||||||
insertion: .move(edge: .top).combined(with: .opacity),
|
|
||||||
removal: .opacity
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.spring(response: 0.35, dampingFraction: 0.78),
|
|
||||||
value: store.clockoutCelebrationActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var celebrationBanner: some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text("🎉 今日打卡下班 🎉")
|
|
||||||
.font(.system(size: 16, weight: .bold))
|
|
||||||
.foregroundColor(.black)
|
|
||||||
Text("辛苦了,到点了,关 IDE 下班!")
|
|
||||||
.font(.system(size: 11, weight: .medium))
|
|
||||||
.foregroundColor(.black.opacity(0.7))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [WorkerTheme.lime, WorkerTheme.tomato.opacity(0.95)],
|
|
||||||
startPoint: .leading, endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
.shadow(color: WorkerTheme.lime.opacity(0.45), radius: 16, x: 0, y: 4)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status
|
// MARK: - Status
|
||||||
@ -274,74 +235,8 @@ struct ClockoutView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Weekend mode
|
|
||||||
|
|
||||||
/// Weekend-mode hero — replaces the countdown card with a purple
|
|
||||||
/// "today's a weekend" panel. The clockout countdown still works
|
|
||||||
/// in the background (anyone working Saturday's edge case), but
|
|
||||||
/// the default UI no longer rubs in the fact that there's
|
|
||||||
/// theoretically a clockout time on a day no one's working.
|
|
||||||
private var weekendHero: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Circle()
|
|
||||||
.fill(WorkerTheme.weekendPurple)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
.shadow(color: WorkerTheme.weekendPurple.opacity(0.7), radius: 4)
|
|
||||||
Text("今天不上班")
|
|
||||||
.font(.system(size: 12.5, weight: .semibold))
|
|
||||||
.foregroundColor(WorkerTheme.fg85)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.frame(height: 26)
|
|
||||||
.background(Capsule().fill(WorkerTheme.overlay06))
|
|
||||||
|
|
||||||
Text("🌴")
|
|
||||||
.font(.system(size: 64))
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
Text("今天是周末")
|
|
||||||
.font(.system(size: 22, weight: .bold))
|
|
||||||
.foregroundColor(WorkerTheme.fgPrimary)
|
|
||||||
|
|
||||||
Text("去做你想做的事")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundColor(WorkerTheme.fg55)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 28)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
WorkerTheme.weekendPurple.opacity(0.18),
|
|
||||||
WorkerTheme.weekendPurple.opacity(0.04)
|
|
||||||
],
|
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.stroke(WorkerTheme.weekendPurple.opacity(0.35), lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekendTipText: some View {
|
|
||||||
Text("如果你周末也上班,记得在 周末 tab 取消休息时调整设置。\n(暂时还没做这个 toggle — 反正下周一会自动恢复倒计时。)")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(WorkerTheme.fg45)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tipText: some View {
|
private var tipText: some View {
|
||||||
Text("到点会有通知 + 一段 Glass 音效庆祝下班\n每日只触发一次,重启插件不会重复响铃。")
|
Text("下班时间到时不会响铃 —\n本插件只负责让你看着时间倒计时偷着乐。")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(WorkerTheme.fg45)
|
.foregroundColor(WorkerTheme.fg45)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|||||||
@ -148,7 +148,7 @@ struct ExpandedView: View {
|
|||||||
Text(footerLeftText)
|
Text(footerLeftText)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("v0.3.0 · 本地运行")
|
Text("v0.1 · 本地运行")
|
||||||
}
|
}
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(WorkerTheme.fg40)
|
.foregroundColor(WorkerTheme.fg40)
|
||||||
|
|||||||
@ -116,10 +116,12 @@ struct PomodoroView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var progressFraction: CGFloat {
|
private var progressFraction: CGFloat {
|
||||||
// P0 fix (2026-05-19 review): delegate to store so paused-in-break
|
let total: Int
|
||||||
// gets the right denominator. Inline switch couldn't see the
|
switch store.pomodoroPhase {
|
||||||
// private pausedPhase.
|
case .focus: total = store.pomodoroFocusMin * 60
|
||||||
let total = store.pomodoroPhaseTotalSec
|
case .rest: total = store.pomodoroBreakMin * 60
|
||||||
|
case .paused, .idle: total = store.pomodoroFocusMin * 60
|
||||||
|
}
|
||||||
guard total > 0 else { return 0 }
|
guard total > 0 else { return 0 }
|
||||||
let remaining = max(0, store.pomodoroRemaining)
|
let remaining = max(0, store.pomodoroRemaining)
|
||||||
return CGFloat(total - remaining) / CGFloat(total)
|
return CGFloat(total - remaining) / CGFloat(total)
|
||||||
@ -201,7 +203,7 @@ struct PomodoroView: View {
|
|||||||
// MARK: - Stats
|
// MARK: - Stats
|
||||||
|
|
||||||
private var statsRow: some View {
|
private var statsRow: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 10) {
|
||||||
statCard(
|
statCard(
|
||||||
title: "今日番茄",
|
title: "今日番茄",
|
||||||
value: "\(store.pomodoroTodayCount)",
|
value: "\(store.pomodoroTodayCount)",
|
||||||
@ -212,30 +214,8 @@ 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, 12)
|
.padding(.horizontal, 16)
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|||||||
@ -11,10 +11,7 @@ struct WaterView: View {
|
|||||||
@ObservedObject var store: WorkerStore
|
@ObservedObject var store: WorkerStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// No ScrollView — panel body sits at ~470pt available and we
|
VStack(spacing: 18) {
|
||||||
// tune everything below to fit: tighter VStack spacing, smaller
|
|
||||||
// cup hero, toggles on a single row, no tipText.
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
statusBadge
|
statusBadge
|
||||||
|
|
||||||
cupHero
|
cupHero
|
||||||
@ -26,73 +23,12 @@ struct WaterView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
goalRow
|
goalRow
|
||||||
toggleRow
|
|
||||||
|
tipText
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.top, 6)
|
.padding(.top, 8)
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Toggle row (compact horizontal layout — saves vertical
|
|
||||||
// space so the whole tab fits without a scroll bar).
|
|
||||||
|
|
||||||
private var toggleRow: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
togglePill(
|
|
||||||
icon: "timer",
|
|
||||||
label: "🍅 +1",
|
|
||||||
isOn: store.waterAutoFromPomodoro,
|
|
||||||
tint: WorkerTheme.tomato,
|
|
||||||
action: { store.waterSetAutoFromPomodoro(!store.waterAutoFromPomodoro) }
|
|
||||||
)
|
|
||||||
togglePill(
|
|
||||||
icon: "bell.fill",
|
|
||||||
label: "整点提醒",
|
|
||||||
isOn: store.waterHourlyReminder,
|
|
||||||
tint: WorkerTheme.water,
|
|
||||||
action: { store.waterSetHourlyReminder(!store.waterHourlyReminder) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compact toggle for the horizontal toggleRow. Icon + short label +
|
|
||||||
/// inline slim toggle track inside a single capsule. Sized to fit
|
|
||||||
/// 2 across a 380pt panel with 16pt horizontal padding.
|
|
||||||
private func togglePill(icon: String, label: String, isOn: Bool, tint: Color, action: @escaping () -> Void) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 10, weight: .semibold))
|
|
||||||
.foregroundColor(isOn ? tint : WorkerTheme.fg55)
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 11, weight: .medium))
|
|
||||||
.foregroundColor(isOn ? WorkerTheme.fgPrimary : WorkerTheme.fg70)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer(minLength: 4)
|
|
||||||
ZStack(alignment: isOn ? .trailing : .leading) {
|
|
||||||
Capsule()
|
|
||||||
.fill(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08)
|
|
||||||
.frame(width: 24, height: 14)
|
|
||||||
Circle()
|
|
||||||
.fill(isOn ? tint : WorkerTheme.fg55)
|
|
||||||
.frame(width: 10, height: 10)
|
|
||||||
.padding(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 30)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(WorkerTheme.overlay04)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08, lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status
|
// MARK: - Status
|
||||||
@ -122,24 +58,23 @@ struct WaterView: View {
|
|||||||
// MARK: - Cup hero (tappable)
|
// MARK: - Cup hero (tappable)
|
||||||
|
|
||||||
private var cupHero: some View {
|
private var cupHero: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 12) {
|
||||||
Button(action: { store.waterAddCup() }) {
|
Button(action: { store.waterAddCup() }) {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Water-fill cup — sized down from 130×160 to fit
|
// Water-fill cup illustration.
|
||||||
// the 380×580 panel without scroll.
|
|
||||||
cupShape
|
cupShape
|
||||||
.frame(width: 100, height: 130)
|
.frame(width: 130, height: 160)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("点击杯子记录一杯水")
|
.help("点击杯子记录一杯水")
|
||||||
|
|
||||||
// Progress dots
|
// Progress dots
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 6) {
|
||||||
ForEach(0..<store.waterGoal, id: \.self) { i in
|
ForEach(0..<store.waterGoal, id: \.self) { i in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(i < store.waterCupsToday ? WorkerTheme.water : WorkerTheme.overlay08)
|
.fill(i < store.waterCupsToday ? WorkerTheme.water : WorkerTheme.overlay08)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 9, height: 9)
|
||||||
.overlay(
|
.overlay(
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(WorkerTheme.overlay12, lineWidth: 0.5)
|
.stroke(WorkerTheme.overlay12, lineWidth: 0.5)
|
||||||
@ -272,6 +207,16 @@ struct WaterView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tipText: some View {
|
||||||
|
Text(store.waterCupsToday >= store.waterGoal
|
||||||
|
? "今天的水喝够了,给自己鼓个掌 👏"
|
||||||
|
: "保持每小时一杯,工作更高效。")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(WorkerTheme.fg45)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cup outline shape
|
// MARK: - Cup outline shape
|
||||||
|
|||||||
@ -11,18 +11,13 @@ struct WeekendView: View {
|
|||||||
@ObservedObject var store: WorkerStore
|
@ObservedObject var store: WorkerStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
VStack(spacing: 18) {
|
||||||
VStack(spacing: 14) {
|
|
||||||
statusBadge
|
statusBadge
|
||||||
|
|
||||||
heroCountdown
|
heroCountdown
|
||||||
|
|
||||||
partsRow
|
partsRow
|
||||||
|
|
||||||
if store.nextHoliday != nil {
|
|
||||||
holidayCard
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.background(WorkerTheme.overlay08)
|
.background(WorkerTheme.overlay08)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
@ -32,80 +27,6 @@ struct WeekendView: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Holiday card (next 法定假)
|
|
||||||
|
|
||||||
/// Distance-to-next-statutory-holiday card. Splits visual weight
|
|
||||||
/// with the weekend hero so users on a Friday don't just see "1 天
|
|
||||||
/// 2 时" and miss that 国庆放 8 天 is next week.
|
|
||||||
private var holidayCard: some View {
|
|
||||||
Group {
|
|
||||||
if let h = store.nextHoliday {
|
|
||||||
holidayContent(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func holidayContent(_ h: UpcomingHoliday) -> some View {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
// Left side: name + days-off line
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "party.popper.fill")
|
|
||||||
.font(.system(size: 11, weight: .semibold))
|
|
||||||
.foregroundColor(WorkerTheme.tomato)
|
|
||||||
Text(h.isOngoing ? "假期中" : "下个法定假")
|
|
||||||
.font(.system(size: 10.5, weight: .medium))
|
|
||||||
.foregroundColor(WorkerTheme.fg55)
|
|
||||||
.tracking(0.3)
|
|
||||||
}
|
|
||||||
Text(h.name)
|
|
||||||
.font(.system(size: 15, weight: .bold))
|
|
||||||
.foregroundColor(WorkerTheme.fgPrimary)
|
|
||||||
Text("放 \(h.days) 天")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(WorkerTheme.fg55)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
// Right side: distance / "假期中"
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
if h.isOngoing {
|
|
||||||
Text("🎉")
|
|
||||||
.font(.system(size: 28))
|
|
||||||
} else {
|
|
||||||
Text("\(h.daysUntil)")
|
|
||||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
||||||
.foregroundColor(WorkerTheme.tomato)
|
|
||||||
.monospacedDigit()
|
|
||||||
Text("天后")
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(WorkerTheme.fg55)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
WorkerTheme.tomato.opacity(0.10),
|
|
||||||
WorkerTheme.tomato.opacity(0.02)
|
|
||||||
],
|
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(WorkerTheme.tomato.opacity(0.30), lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status
|
// MARK: - Status
|
||||||
|
|||||||
@ -1,294 +0,0 @@
|
|||||||
# 摸鱼侠 — 代码质量 Review
|
|
||||||
|
|
||||||
**日期**: 2026-05-19
|
|
||||||
**审视分支**: main @ `0cff83fc` ("feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro")
|
|
||||||
**源码规模**: 13 Swift 文件,~1500 行;WorkerStore 700 行最大
|
|
||||||
**测试**: 0(无 XCTest target,无测试文件)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、架构 — 8/10
|
|
||||||
|
|
||||||
```
|
|
||||||
engine/
|
|
||||||
WorkerStore.swift 700-行单体 state + tick + 5 feature 逻辑
|
|
||||||
NotificationCenter.swift UN wrapper + dock fallback
|
|
||||||
SystemIdle.swift CGEvent input-idle 探测
|
|
||||||
WorkerDebugLog.swift /tmp/worker-plugin.log
|
|
||||||
ui/
|
|
||||||
ExpandedView.swift 5-tab shell
|
|
||||||
PomodoroView/SitView/WaterView/ClockoutView/WeekendView.swift
|
|
||||||
Theme.swift 色彩 token + WorkerFormat helpers
|
|
||||||
WorkerPlugin.swift 主类
|
|
||||||
```
|
|
||||||
|
|
||||||
比看盘侠少一层 `data/` — 因为 100% 本地无网络,不需要 actor client。
|
|
||||||
|
|
||||||
### 问题 1:WorkerStore 是 god object
|
|
||||||
|
|
||||||
700 行包含 pomodoro state machine + sit accumulator + water counter + clockout time + weekend countdown + 全部持久化。每 feature 逻辑都在 store 里。当前 OK 因为单体跑得通,长期 `tickFire()` 会膨胀。
|
|
||||||
|
|
||||||
**重构方向 P2**(不阻塞 ship):
|
|
||||||
```
|
|
||||||
PomodoroStore : ObservableObject
|
|
||||||
SitStore : ObservableObject
|
|
||||||
WaterStore : ObservableObject
|
|
||||||
ClockoutStore : ObservableObject
|
|
||||||
WeekendCalculator
|
|
||||||
WorkerStore 只负责 orchestrate 1Hz tick 分发 + persistence routing
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、状态管理 — 9/10(最大亮点)
|
|
||||||
|
|
||||||
### 🌟 亮点 1:pomodoro 用 wallclock endsAt 而非 counter
|
|
||||||
|
|
||||||
`WorkerStore.swift:108 private var pomodoroPhaseEndsAt: Date?`。tick 每次算 `remaining = endsAt - now`。
|
|
||||||
|
|
||||||
**含义**:Mac 睡了 / quit / 关 panel / 重启 都不影响倒计时正确性(按现实流逝)。比天真版"每秒减 1"强多了。
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// WorkerStore.swift:203-207
|
|
||||||
if let endsAt = pomodoroPhaseEndsAt,
|
|
||||||
pomodoroPhase == .focus || pomodoroPhase == .rest {
|
|
||||||
let remaining = max(0, Int(endsAt.timeIntervalSinceNow))
|
|
||||||
pomodoroRemaining = remaining
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🌟 亮点 2:paused 持久化
|
|
||||||
|
|
||||||
`WorkerStore.swift:584-624` — paused phase 在 launch 时检测到 `savedPhase == .paused` 跳过 endsAt 计算,直接 resume 到上次 pause 时的 remaining。**正确**。
|
|
||||||
|
|
||||||
### 🌟 亮点 3:sit accumulator 用 input-idle,不用 timestamp
|
|
||||||
|
|
||||||
`WorkerStore.swift:225-244` sit 增长依赖 `SystemIdle.seconds < 60`,> 5 min idle reset。配合 `didSleepWake` (wallclock gap > 30s) 检测 → 三层保护。
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 三层 reset:
|
|
||||||
if didSleepWake { ... } // 1. Mac 睡了
|
|
||||||
else if idle >= sitBreakResetThresholdSec { ... } // 2. 离开 ≥ 5min
|
|
||||||
else if idle < 60 { sitAccumActiveSec += 1 } // 3. 真在打字
|
|
||||||
// idle 1-5 min:hold steady(开会/打电话不归零也不+)
|
|
||||||
```
|
|
||||||
|
|
||||||
**比市面上"久坐提醒"app 都准**。
|
|
||||||
|
|
||||||
### 🔴 问题 1:PomodoroView 进度环 fraction 在 paused-in-break 算错
|
|
||||||
|
|
||||||
`PomodoroView.swift:118-128`:
|
|
||||||
```swift
|
|
||||||
private var progressFraction: CGFloat {
|
|
||||||
let total: Int
|
|
||||||
switch store.pomodoroPhase {
|
|
||||||
case .focus: total = store.pomodoroFocusMin * 60
|
|
||||||
case .rest: total = store.pomodoroBreakMin * 60
|
|
||||||
case .paused, .idle: total = store.pomodoroFocusMin * 60 // ← BUG
|
|
||||||
}
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
paused 状态下 `total` 永远当 focus 时长。但用户可能在 **break 中按了暂停**(`pomodoroPause()` 在 focus/rest 都允许)。这时 `pausedRemaining` 是 break 的剩余,total 是 focusMin × 60 — fraction 算错(分母不对)。
|
|
||||||
|
|
||||||
**修法**:暴露 store.pausedPhase 给 view 或在 store 提供 `pomodoroPhaseTotalSec` computed:
|
|
||||||
```swift
|
|
||||||
var pomodoroPhaseTotalSec: Int {
|
|
||||||
switch pomodoroPhase {
|
|
||||||
case .focus: return pomodoroFocusMin * 60
|
|
||||||
case .rest: return pomodoroBreakMin * 60 // 或长休
|
|
||||||
case .paused: return pausedPhase == .rest ? pomodoroBreakMin * 60 : pomodoroFocusMin * 60
|
|
||||||
case .idle: return pomodoroFocusMin * 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、并发 — 9/10
|
|
||||||
|
|
||||||
### 强项
|
|
||||||
|
|
||||||
- `@MainActor` 在 WorkerStore / WorkerNotificationCenter
|
|
||||||
- `Timer.scheduledTimer` 在 main RunLoop,cb 内 `Task { @MainActor in }` 隔离正确
|
|
||||||
- `@preconcurrency import UserNotifications` (`NotificationCenter.swift:13`) — 处理 UN API 非全 Sendable
|
|
||||||
|
|
||||||
### 🔴 问题 1:notifAuthorized 没 wire 到 store,状态 dot 永 dim
|
|
||||||
|
|
||||||
`NotificationCenter.swift:32-46` callback 内 `WorkerNotificationCenter.shared.isAuthorized = true` —— 但 `WorkerStore.shared.notificationsAuthorized` (`WorkerStore.swift:149`) **没人 wire**。`ExpandedView.swift:84` 读 `store.notificationsAuthorized` 永远是初始值 `false`。
|
|
||||||
|
|
||||||
**结果**:top bar 通知状态 dot 永远是 dim grey,即使用户实际授权了通知。
|
|
||||||
|
|
||||||
**修法**:`NotificationCenter.swift:34`
|
|
||||||
```swift
|
|
||||||
Task { @MainActor in
|
|
||||||
WorkerNotificationCenter.shared.isAuthorized = true
|
|
||||||
WorkerStore.shared.notificationsAuthorized = true // ← 加这行
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、通知 — 7/10
|
|
||||||
|
|
||||||
### 强项
|
|
||||||
|
|
||||||
- UN authorization 三态正确处理(authorized/denied/notDetermined)
|
|
||||||
- 0.1s threshold trigger 处理 macOS 即时通知 flakiness 是 known workaround
|
|
||||||
- 通知 denied → fallback `NSApp.requestUserAttention(.criticalRequest)` (dock bounce)
|
|
||||||
|
|
||||||
### 问题 1:notify() 即便授权也每次都 dock bounce
|
|
||||||
|
|
||||||
`NotificationCenter.swift:74` `NSApp.requestUserAttention(.criticalRequest)` 在 schedule 之后**无条件**调用,注释说"always bounce as backup"。但用户授权通知后 dock 还在 bounce 会 spammy。
|
|
||||||
|
|
||||||
**修法**:
|
|
||||||
```swift
|
|
||||||
if !isAuthorized {
|
|
||||||
NSApp.requestUserAttention(.criticalRequest)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 2:没 snooze 机制
|
|
||||||
|
|
||||||
番茄结束通知 / 久坐警告 fire-and-forget。用户在开会,一次注意不到就丢。**建议**:5 min 后 retry 一次。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、持久化 — 7/10
|
|
||||||
|
|
||||||
用 `UserDefaults(suiteName: "com.mioisland.plugin.worker")`。5 个 feature 数据全塞同一 suite。简单 key-value,OK。
|
|
||||||
|
|
||||||
### 问题 1:历史 dict 增长无 cap
|
|
||||||
|
|
||||||
`WorkerStore.swift:403-406` `pomodoroHistory` 每日 key 累积。一年 365 keys,五年 1825。**建议**:保留最近 90 天,rolloverIfNeeded 时 prune。
|
|
||||||
|
|
||||||
### 问题 2:lastSeenDay 不持久
|
|
||||||
|
|
||||||
`WorkerStore.swift:539 private var lastSeenDay: String = ""`. 重启后 init 时被设成 today (`loadPersisted` 末尾)。day rollover 检测靠这个。跨日运行(不重启)能 catch;新一天才启动则 `lastSeenDay = today` 直接,rolloverIfNeeded 永不 fire(OK,data 也确实新一天 0)。**逻辑 work 但脆**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、错误处理 — 6/10
|
|
||||||
|
|
||||||
### 问题 1:SystemIdle 失败返回 0 → silent 误 grow
|
|
||||||
|
|
||||||
`SystemIdle.swift:34`:
|
|
||||||
```swift
|
|
||||||
return minVal == .greatestFiniteMagnitude ? 0 : minVal
|
|
||||||
```
|
|
||||||
|
|
||||||
若 `CGEventSource` 全部失败(理论上可能 sandbox 拒绝),返回 0 = "用户刚刚操作了" = sit counter 每秒 +1。**结果**:sit counter 误 grow,假警报触发。
|
|
||||||
|
|
||||||
**修法**:返回 `nil` 或 sentinel,store 里检测无效 idle 数据时 skip tick。
|
|
||||||
|
|
||||||
### 问题 2:UNUserNotificationCenter.add 错误只日志
|
|
||||||
|
|
||||||
`NotificationCenter.swift:67-69` — 错误只写 `WorkerDebugLog`,无 UI 信号。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、UI — 7/10
|
|
||||||
|
|
||||||
### 强项
|
|
||||||
|
|
||||||
- 5 个 view 风格高度一致(statusBadge + hero + controls + divider + settings + tipText)
|
|
||||||
- `WorkerTheme` 集中色彩 token,`WorkerFormat` 集中时间格式
|
|
||||||
- 各 view emoji + 中文,跟产品 personality 匹配
|
|
||||||
|
|
||||||
### 🔴 问题 1:PomodoroView 进度环 paused-in-break 错
|
|
||||||
|
|
||||||
(见状态管理问题 1)
|
|
||||||
|
|
||||||
### 🔴 问题 2:ExpandedView 通知状态 dot 永 dim
|
|
||||||
|
|
||||||
(见并发问题 1)
|
|
||||||
|
|
||||||
### 问题 3:WaterView cup mask 不跟 trapezoid 收边
|
|
||||||
|
|
||||||
`WaterView.swift:99-108` 水位 mask 用 `Rectangle`,但 cupShape (`CupOutline`) 是 trapezoid (bottomInset 8%)。`Rectangle` mask 不跟着杯子底部收边收缩。
|
|
||||||
|
|
||||||
**视觉效果**:低水位时基本看不出来;满水位接近 outline 那里水位 fill 会越过杯壁 — 渲染上可见的 leak。
|
|
||||||
|
|
||||||
**修法**:mask 用 `CupOutline` 自己 clip(不是 Rectangle):
|
|
||||||
```swift
|
|
||||||
.mask(CupOutline().scale(y: fraction, anchor: .bottom))
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 4:没"今日总览"footer
|
|
||||||
|
|
||||||
5 个 tab 各管各的,没"今日 3 番茄 + 6 杯水 + 监控中" 一行 summary。footer 左侧 (`ExpandedView.swift:169-177 footerLeftText`) 每 tab 显示自己的,无跨 tab 整合。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、安全 — 9/10
|
|
||||||
|
|
||||||
- 100% 本地,无网络
|
|
||||||
- UserDefaults suite name 跟其它 plugin 隔离
|
|
||||||
- `NSApp.requestUserAttention` 是 standard API
|
|
||||||
- 无 entitlement 需求
|
|
||||||
- 无用户输入直接 eval / shell
|
|
||||||
|
|
||||||
唯一关注:`SystemIdle` 用 `CGEventSource` 的 `.combinedSessionState` source 不需要 Accessibility 权限。Apple 列为 sandbox 友好 API。**OK**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、测试 — 0/10
|
|
||||||
|
|
||||||
**No tests at all**。
|
|
||||||
|
|
||||||
**v1 必须有的**:
|
|
||||||
|
|
||||||
| 测试场景 | 难度 |
|
|
||||||
|---|---|
|
|
||||||
| Pomodoro state machine:focus → rest → focus × 4 → long rest → focus(autoLoop)| 易(注入 mock Date)|
|
|
||||||
| Pomodoro paused → resume 跨时间正确 | 易 |
|
|
||||||
| Pomodoro phase end while away(`loadPersisted` 路径)| 中 |
|
|
||||||
| Sit accumulator:idle <60 += 1; 60..300 hold; ≥300 reset | 易 |
|
|
||||||
| Sit wakeup gap:tickGap > 30s reset | 易 |
|
|
||||||
| Day rollover:跨 midnight 各 counter 归零 | 中 |
|
|
||||||
| Weekend countdown 跨 Saturday 边界 | 易 |
|
|
||||||
| ClockoutRemainingSec 跨 midnight roll | 易 |
|
|
||||||
| WaterCupsToday 持久化 + restore | 易 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、Code smell 汇总
|
|
||||||
|
|
||||||
| 文件:行 | 问题 | 优先级 | 修复行数 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `PomodoroView.swift:118` | paused-in-break fraction 用 focusMin 当分母 | 🔴 P0 | 5 |
|
|
||||||
| `ExpandedView.swift:84` + `NotificationCenter.swift:34` | notifAuthorized 未 wire 到 store,dot 永 dim | 🔴 P0 | 3 |
|
|
||||||
| `NotificationCenter.swift:74` | 授权后还 dock bounce 体验吵 | 🟡 P1 | 3 |
|
|
||||||
| `SystemIdle.swift:34` | 失败 fallback 0 → 误 grow sit counter | 🟡 P1 | 改 nil |
|
|
||||||
| `WaterView.swift:99-108` | mask 不跟 trapezoid 收边 | 🟡 P1 | 改 mask |
|
|
||||||
| `WorkerStore.swift:403` | pomodoroHistory / waterHistory 无 cleanup | 🟢 P2 | 90 天 prune |
|
|
||||||
| `WorkerStore.swift` | 700 行 god object | 🟢 P2 | 拆 store |
|
|
||||||
| 全 repo | 0 tests | 🟡 P1 | 加 XCTest |
|
|
||||||
| `ExpandedView.swift:151` | footer 版本号硬编码"v0.1" | 🟢 P2 | 读 Plugin.version |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、总评
|
|
||||||
|
|
||||||
代码质量 **8/10** — 实际上比看盘侠更扎实。
|
|
||||||
|
|
||||||
### 亮点(值得保留 + 文档化)
|
|
||||||
|
|
||||||
1. **pomodoro wallclock-based timing** — Mac 睡/quit/手动改时钟都不影响
|
|
||||||
2. **sit input-idle algorithm** — 比天真 startTimestamp 模型强一档
|
|
||||||
3. **sleep-aware tick gap detection** — 三层保护
|
|
||||||
4. **paused state 持久化** — 跨重启恢复正确
|
|
||||||
5. **统一 view 结构** — 5 个 tab 风格高度一致
|
|
||||||
|
|
||||||
### 主要短板
|
|
||||||
|
|
||||||
1. **notifAuthorized 没 wire** — UI 上 notif 状态 dot 永远说谎
|
|
||||||
2. **PomodoroView 进度环算错**(pause 在 break 中)
|
|
||||||
3. **WorkerStore 700 行 god object** — 长期维护痛
|
|
||||||
4. **0 tests** — 上架后崩了不知道哪坏
|
|
||||||
|
|
||||||
修完 P0 两项 + 加最小测试集(5 个 fixture),code quality 能上 9.5/10。
|
|
||||||
|
|
||||||
**整体判断**:v0.2.0 的代码扎实度足以支撑上架。两个 P0 bug(notif dot + paused fraction)一晚上能修完。比看盘侠 v0.3.0 的 Toast bug 更轻 — 后者是 typo 级 ship-blocker。
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
# 摸鱼侠 — 产品 Review
|
|
||||||
|
|
||||||
**日期**: 2026-05-19
|
|
||||||
**审视版本**: v0.2.0 main HEAD `0cff83fc`("sleep-aware timers + auto-loop pomodoro")
|
|
||||||
**Tabs**: 番茄 / 久坐 / 喝水 / 下班 / 周末
|
|
||||||
**面板**: 380×620
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、核心价值主张
|
|
||||||
|
|
||||||
| 维度 | 评分 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| 差异化 | 9/10 | 聚合 5 个上班场景,刘海常驻,"周末倒数 + 打工语录" 玩具感 |
|
|
||||||
| 价值密度 | 8/10 | 单一 plugin 覆盖 4 个独立细分(番茄 / 久坐 / 喝水 / 倒计时)|
|
|
||||||
| 上手摩擦 | 9/10 | 0 登录 0 网络 0 配置就能用 |
|
|
||||||
|
|
||||||
### 强项
|
|
||||||
|
|
||||||
1. **聚合"上班一天会做的微动作"** — 番茄钟 + 久坐 + 喝水 + 下班倒计时已经覆盖职场版"健康助手"大头。市面上要么单功能 menubar (Be Focused / Drink Water Reminder),要么大 app (Forest / Notion)。集中在刘海是新形态。
|
|
||||||
2. **"周末倒数 + 打工语录"是 personality 担当** — 这一 tab 跟其它四个分开看是娱乐性的,但它是产品个性的来源。没这个 tab 就是健康提醒 app,有了它就是"打工人玩具"。
|
|
||||||
3. **100% 本地** — 没网络、没账号、没 telemetry。比 Forest 联网+social pressure 更适合"我自己用"的人。
|
|
||||||
|
|
||||||
### 弱项
|
|
||||||
|
|
||||||
1. **"摸鱼侠"名字 vs 5 个 tab 全在认真工作 = 反差**
|
|
||||||
名字暗示"摸鱼",实际:番茄(专注)、久坐(健康)、喝水(健康)、下班(期待)、周末(期待)。**整个工具是"老老实实打工 toolkit"**。要么改名("打工侠"更准),要么真加一个摸鱼 tab(藏老板模式 / 桌面伪装 / 假装写代码的 dummy editor)。
|
|
||||||
2. **5 tab 在 380pt 太挤** — `ExpandedView.swift:97` spacing 6 + 5 个 emoji tab,单 tab ~64pt。看是看见,但触摸/点击 hit zone 比看盘侠 3-tab 明显窄。
|
|
||||||
3. **README 与 footer/Info.plist 版本号不一致** — `ExpandedView.swift:151` footer 硬编码 "v0.1 · 本地运行",但 Info.plist + WorkerPlugin.swift 是 v0.2.0。每次发版都得手改文案。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、UX 流程完整度(按 tab 拆)
|
|
||||||
|
|
||||||
### 番茄 tab — 9/10 (最完善)
|
|
||||||
|
|
||||||
✅ 25/5 + 长休息 cycle 完整
|
|
||||||
✅ pause/resume 跨重启正确(`WorkerStore.swift:594-600` paused 持久化)
|
|
||||||
✅ **sleep-aware**:`WorkerStore.swift:198-200` 用 tick 间隔检测 wallclock gap,Mac 睡眠后倒计时不错乱
|
|
||||||
✅ 4-tomato dot 周期可视化(`PomodoroView.swift:64`)
|
|
||||||
✅ autoLoop toggle — 经典 Pomodoro 节奏,一启动跑一上午
|
|
||||||
|
|
||||||
**短板**:
|
|
||||||
- ⚠️ stepperPill 单位 +5/+1/+5 min,专注分钟想要 +1 step(27 分钟用例)
|
|
||||||
- ⚠️ 没"中断当前番茄"button — 重置可以替代但语义不同
|
|
||||||
- ⚠️ "专注时长 = todayCount × focusMin" 算法 simplistic — partial focus 丢失。无所谓,不算精确没事
|
|
||||||
|
|
||||||
### 久坐 tab — 9/10 (算法亮点)
|
|
||||||
|
|
||||||
✅ **input-idle 算法是核心亮点** — `SystemIdle.swift` 用 `CGEventSource.secondsSinceLastEventType` 判定真在打字 vs 离开。避免天真版"start timestamp - now"把午休/会议都算成坐着的 trap。
|
|
||||||
✅ 5min idle 阈值 reset;wallclock gap >30s 也 reset(sleep/wake)— 双保险
|
|
||||||
✅ threshold 之后 dock bounce + 红色 status pill + 通知(30s 持久化 cadence,崩溃最多丢 30s data)
|
|
||||||
✅ tip 文案诚实:"只统计你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被算进去。"
|
|
||||||
|
|
||||||
**短板**:
|
|
||||||
- ⚠️ 没"今日累计坐了 X 小时"统计 — 只显示当前 streak,无历史 retention。"健康报告"路线钩子缺
|
|
||||||
- ⚠️ trigger 配置 +5 min step、下限 5 min — 想要 60min trigger 要点 3 次。可以加 +15 min step
|
|
||||||
|
|
||||||
### 喝水 tab — 7/10 (placeholder 级)
|
|
||||||
|
|
||||||
✅ Hero 大杯子 + 水位渐变填充动画细心(`WaterView.swift` CupOutline + LinearGradient mask)
|
|
||||||
✅ 进度 dot 可视化今日 x/N 杯
|
|
||||||
✅ 加 / 撤销 manual log,撤销 -∞ 守住(`WorkerStore.swift:455`)
|
|
||||||
|
|
||||||
**短板**:
|
|
||||||
- 🔴 **没提醒** — 跟番茄/久坐对比最大短板。设了"目标 8 杯"但不提醒。一整天忘了喝就没了。**P0**:加 X 小时一杯水的 cadence + 通知(autoLoop pomodoro-style)
|
|
||||||
- ⚠️ 历史不可见 — `waterHistory` dict 里其实存了过往天数据,没 UI
|
|
||||||
- ⚠️ 没编辑"今早 8 点喝了 1 杯但忘了点"功能 — 只能 + / 撤销 当前
|
|
||||||
|
|
||||||
### 下班 tab — 7/10 (倒计时到 0 没事件)
|
|
||||||
|
|
||||||
✅ "X 时 Y 分 Z 秒" + 渐变进度条(红→深红)— 视觉感染力强
|
|
||||||
✅ 9 小时 anchor 算 progress fraction —合理
|
|
||||||
✅ 时间 hh:mm 可调(5 min step)
|
|
||||||
|
|
||||||
**短板**:
|
|
||||||
- 🔴 **"下班到了"那一刻没事件** — `clockoutRemainingSec` 到 0 后 roll 到明天的同一时间(`WorkerStore.swift:491-495 if target <= now { target += 1 day }`)。所以下班到了下一秒变成"距明天下班 23:59:59"。**漏的体验**:应该有"已下班!" celebration overlay 或 panel 颜色变。**P0**
|
|
||||||
- ⚠️ tipText 说"下班时间到不会响铃" — 这是文案 disclaimer 但产品上反而是缺陷。**应该响**
|
|
||||||
- ⚠️ 没"周末忽略" — 周六也倒计时下班,应该周末显示"今天不上班"
|
|
||||||
- ⚠️ 默认 18:00;9-9-6 / 灵活 OT 党需改。OK 可调,但没"工时模式"预设
|
|
||||||
|
|
||||||
### 周末 tab — 8/10 (personality 担当)
|
|
||||||
|
|
||||||
✅ "天/时/分 + 下个周六 00:00" + 旋转打工语录 — 形态独特
|
|
||||||
✅ 周末打开倒计时 +7 天 — 决策合理(`WorkerStore.swift:518-519`)
|
|
||||||
✅ day-of-year mod 7 的 quote rotation 一天一条稳定
|
|
||||||
|
|
||||||
**短板**:
|
|
||||||
- ⚠️ **打工语录池只有 7 条** — 52 周看 52 次每条。重复感会重。**P1 扩到 30+**
|
|
||||||
- ⚠️ **没"假期校准"** — 国庆/春节怎么办?周末倒计时还在算下周六。逻辑对但用户期待"放长假倒计时"。**P1 接入国务院 ICS feed**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、信息架构
|
|
||||||
|
|
||||||
| 强项 | 弱项 |
|
|
||||||
|---|---|
|
|
||||||
| 每个 tab 一致 layout:statusBadge + hero + controls + divider + settings + tipText | 没"全局 dashboard" — 5 tab 间不互通 |
|
|
||||||
| LiveDot 在 footer 闪 — plugin 在 active 计时 | 没"今日 X 番茄 + Y 杯水 + Z 分钟久坐" 一行 summary |
|
|
||||||
| notif status dot 在 top bar — 通知未授权时的隐性提醒 | tab 之间数据点孤岛 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、与竞品对比
|
|
||||||
|
|
||||||
| | 摸鱼侠 | Be Focused | Drink Water | Forest |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 桌面常驻 | ✅ 刘海 | ✅ menubar | ✅ menubar | ❌ App |
|
|
||||||
| 番茄钟 | ✅ + auto-loop | ✅ | ❌ | ✅ |
|
|
||||||
| 久坐提醒 | ✅ input-idle | ❌ | ❌ | ❌ |
|
|
||||||
| 喝水追踪 | ✅(无提醒)| ❌ | ✅ | ❌ |
|
|
||||||
| 下班倒计时 | ✅ | ❌ | ❌ | ❌ |
|
|
||||||
| 周末倒数 | ✅ | ❌ | ❌ | ❌ |
|
|
||||||
| 通知 | UN + dock | ✅ | ✅ | ✅ |
|
|
||||||
| 价格 | 免费 | $1.99 | 免费 | $1.99 |
|
|
||||||
| Social pressure | ❌ | ❌ | ❌ | ✅(种树)|
|
|
||||||
| 中文体验 | ✅✅ | ❌ | ❌ | ⚠️ |
|
|
||||||
|
|
||||||
**差异化**:聚合 + 桌面常驻 + 中文文案 + 周末打工语录。"打工人玩具"比 Western productivity tool 中文用户更亲切。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、上架前必修(P0)
|
|
||||||
|
|
||||||
| # | 项 | 文件 / 位置 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | "下班到了"瞬间要有通知 + panel celebration overlay | `WorkerStore.swift:491-495` clockoutRemainingSec roll 逻辑 |
|
|
||||||
| 2 | 喝水加可选定时提醒(每 X 小时) | 新增 water reminder timer |
|
|
||||||
| 3 | footer 版本号去硬编码 | `ExpandedView.swift:151` 改读 `WorkerPlugin.version` |
|
|
||||||
| 4 | tipText "不会响铃" 删掉或改写 — 跟未来要加的通知冲突 | `ClockoutView.swift:239` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、建议加(P1)
|
|
||||||
|
|
||||||
1. **真的"摸鱼" tab** — 跟名字呼应:藏老板模式 / 桌面伪装 / dummy editor。是 product personality 的兑现
|
|
||||||
2. **打工语录池扩到 30+** — 现在 7 条一周一轮太短,季节性条目(春节前 / 国庆前 / 周一专属 / 周五专属)
|
|
||||||
3. **假期/节假日校准** — 国务院假期 ICS pull 一次,下班 + 周末倒计时知道明天是法定休
|
|
||||||
4. **今日历史 chart** — 7 天番茄 / 喝水 / 久坐 trend,鼓励 streak
|
|
||||||
5. **快捷键** — `cmd-shift-1/2/3/4/5` 切 tab,`space` 番茄 start/pause
|
|
||||||
6. **WaterView 历史可见** — `waterHistory` 数据已经在存,画个 7 天 bar chart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、建议砍(P-1)
|
|
||||||
|
|
||||||
- README 提到 macOS 15.0+ / MioIsland v2.2.0+ 是 OK,但应在产品文案显式说"需要 Apple Silicon"(bundle arm64-only)— 这是 ecosystem 级问题,跟主程序 + 其它插件统一改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、总评
|
|
||||||
|
|
||||||
| 问 | 答 |
|
|
||||||
|---|---|
|
|
||||||
| v0.2.0 上架免费版? | **是** |
|
|
||||||
| 功能完成度? | 番茄 9/10 + 久坐 9/10 是亮点;喝水 7/10 + 下班 7/10 是 placeholder 级;周末 8/10 是 personality 担当 |
|
|
||||||
| 上架前必修项数? | 4 个(见上)|
|
|
||||||
| 最大产品风险 | "摸鱼侠"名字 vs 实际功能反差。要么改名要么真做摸鱼 tab |
|
|
||||||
| 最大产品亮点 | input-idle 久坐算法 + sleep-aware 番茄 wallclock — 工程支撑产品体验,比同类强一档 |
|
|
||||||
|
|
||||||
**优先级 sequence**:修 P0 四项 → 上架免费 → 做"真摸鱼 tab"兑现名字 → 加节假日校准 + 历史 chart 增 retention。
|
|
||||||
Loading…
Reference in New Issue
Block a user