Compare commits

...

12 Commits
v0.2.0 ... main

Author SHA1 Message Date
徐翔宇
0d2d844538 feat(sound): swap sit/clockout tones for better character
- sit:      Morse → Submarine (1.5s sonar). Longer sustain + lower
            pitch pierces meeting audio and noise-cancelling
            headphones better than Morse's crisp staccato. 1Hz
            cadence over 15s still drives urgency.
- clockout: Glass → Hero (1.1s heroic fanfare). Glass was "
            achievement"; Hero is "🎺 victory" — better scale for
            the 下班 moment. Three plays with 0.6s gap.

Both still macOS-built-in (/System/Library/Sounds), zero external
deps. Single hard-coded path per sound — to swap again, edit the
URL line. Function name `playMorseSitAlert` kept for callsite
compat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:26:37 +08:00
徐翔宇
5e5540d9b1 fix(water): remove vertical scroll — fit toggles + hero in 580pt panel
WaterView added two toggle rows which pushed content past panel
height, so it got wrapped in a ScrollView. That introduced a
vertical scroll bar — visually noisy for a tab that should fit at a
glance.

Compaction strategy:
- VStack spacing 14 → 10 (saves ~24pt across 4 gaps)
- cupShape 130×160 → 100×130 (saves ~30pt + width breathing room)
- Big "X" number font 40 → unchanged (still readable in 100pt cup)
- toggleRows (vertical 2-stack of 32pt pills) → toggleRow
  (horizontal 30pt single line with short labels "🍅 +1" / "整点提醒")
- Drop tipText (saves ~30pt) — redundant with the badge text

Net height now fits inside the panel without scroll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:57 +08:00
徐翔宇
dc0d22700c chore: bump v0.3.0 build 3 + holiday data + cross-day water dedupe fix
Three things squashed into one commit since they're all release-prep:

1. Bump CFBundleVersion 2 → 3, CFBundleShortVersionString 0.2.0 →
   0.3.0, WorkerPlugin.version & ExpandedView footer string to match.
   Aligns with the 5-feature v0.3.0 ship.

2. fix(water): lastWaterReminderHr was an Int hour (0-23) so a
   yesterday-fired 17:00 blocked today's 17:00 reminder. Now stores
   "yyyy-MM-dd HH" composite key so dedupe is scoped per-day.

3. Holidays.swift: 2026 entry table re-calibrated per typical
   State Council 调休 pattern. Notable changes:
   - 春节 starts on 除夕 02-16 (not 初一 02-17) per 2024 policy
   - 中秋 + 国庆 merged into 10-day 09-25 ~ 10-04 window (since
     2026 中秋 09-25 is 6 days before 国庆 10-01)
   - 劳动节 renamed from "五一" for clarity
   Disclaimer still in header: verify against 国务院 announcement
   when published, edit + re-release if 调休 differs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:12:40 +08:00
徐翔宇
51cc5bb0cc feat(weekend): show next China statutory holiday countdown
Adds HolidayDatabase with 7 entries for 2026 (元旦/春节/清明/五一/
端午/中秋·国庆 + 2027 元旦 roll-over). WorkerStore.nextHoliday
computed returns the upcoming or currently-ongoing holiday with
days-until. WeekendView gains a tomato-tinted card under the
weekend hero showing "下个法定假 国庆 · 放 8 天 · X 天后" — or
"假期中" + 🎉 when the user opens the panel during the window.

Dates are estimated from typical State Council patterns; the file
header carries a TODO to verify against the official announcement
each year.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:06 +08:00
徐翔宇
2aa68c2427 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>
2026-05-20 09:52:27 +08:00
徐翔宇
4b2ac2fc59 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>
2026-05-20 09:50:46 +08:00
徐翔宇
f0a116ccf7 feat(clockout): weekend "今天不上班" mode
Saturday/Sunday previously kept showing the workday countdown even
though no one's working — UX surfaced friction where there was
nothing to count. Now ClockoutView switches to a purple
weekend-hero card with "🌴 今天是周末 · 去做你想做的事" copy.

Implementation:
- WorkerStore.isWeekend computed (Sun=1, Sat=7)
- ClockoutView.body branches: weekendHero + weekendTipText for
  weekend, original countdown+progress+controls for weekday
- Clockout celebration logic still runs in the background — if
  someone does work Saturday and crosses their configured clockout
  hour, the next-weekday view will see the dedupe key updated;
  no spurious extra fire

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:49:17 +08:00
徐翔宇
d4fea96d17 feat(clockout): celebration on boundary cross
Prior: clockoutRemainingSec rolled forward to tomorrow the moment
target <= now, so the panel jumped 1s → 86399s with no observable
"zero" — the most expected moment of the day was silent.

Now:
- clockoutRemainingSec returns 0 when past today's clockout (sits
  at 0 until midnight rolls the calendar component)
- WorkerStore detects the >0 → 0 edge in tickFire, fires per-day-
  deduped celebration (notification + Glass × 3 + panel banner ~5s)
- SoundPlayer.playClockoutCelebration: Glass.aiff × 3 with 0.6s gap
  (joyful 1.8s shot, not the sit Morse's 15s nag)
- ClockoutView overlays a lime→tomato gradient banner driven by
  store.clockoutCelebrationActive
- tipText rewrite — old text said "不会响铃" which contradicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:48:21 +08:00
徐翔宇
c1747dc1ce fix(sit): reset accumulator after threshold fire
Prior path used a lastFired-timestamp dedupe but never reset the
sitAccumActiveSec counter, so after the first 45min alert the
panel kept showing "已坐 90 分钟" / "135 分钟" — counter monotonic
across cycles. The UX intent is each ack starts a fresh cycle.

Now: on fire, alert + reset counter to 0. Next triggerSec of
sitting kicks a fresh alert. Reset is the simpler dedupe — there's
no physical way to re-fire without accumulating triggerSec from 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:29:28 +08:00
徐翔宇
19c5be91fa feat(sit): 15s Morse alert tone on sit threshold
The UN notification ding is one short beep — easy to miss in a
meeting. For sit alerts, repeat the system Morse tone (0.7s
staccato "S O S") at 1Hz for 15 ticks, layered on top of the
notification + dock bounce.

- new: SoundPlayer.swift — NSSound-based repeating tone with stop()
- WorkerStore.tickFire: trigger SoundPlayer.playMorseSitAlert(count: 15)
  alongside the existing notify() call
- WorkerStore.sitStop: silence in-flight alert when user stops monitoring

Uses /System/Library/Sounds/Morse.aiff — shipped on every macOS,
no resource bundling needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:25:48 +08:00
徐翔宇
3fb2c5c2a8 fix: pomodoro paused-in-break fraction + notif status wired to store
Two P0 issues from 2026-05-19 code review:

1. PomodoroView progress ring used pomodoroFocusMin*60 as denominator
   in paused state. If the user paused mid-break, ring rendered 80%
   done when the underlying break was actually 40% done. Now the
   store exposes pomodoroPhaseTotalSec which reads pausedPhase to
   pick the right denominator.

2. WorkerNotificationCenter set its own isAuthorized but never wrote
   WorkerStore.notificationsAuthorized — the top-bar notif status dot
   stayed dim even after the user authorized. Refactored into a single
   applyAuthorized helper that writes both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:54:28 +08:00
徐翔宇
8022abca76 docs: add 2026-05-19 product + code review
- product-review.md: 5 个 tab 拆评,"摸鱼侠"名字 vs 功能 mismatch
  与 P0 上架前必修项(下班瞬间无事件 / 喝水无提醒 / 版本号硬编码)
- code-review.md: 13 文件审视,标出 PomodoroView paused-in-break
  fraction 算错、notifAuthorized 未 wire 致状态 dot 永 dim 等 P0 项;
  亮点是 wallclock-based pomodoro + input-idle sit accumulator

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:34:56 +08:00
13 changed files with 1252 additions and 82 deletions

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<string>0.3.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>NSPrincipalClass</key>
<string>WorkerPlugin.WorkerPlugin</string>
<!--

View File

@ -13,7 +13,7 @@ final class WorkerPlugin: NSObject, MioPlugin {
var id: String { "worker" }
var name: String { "摸鱼侠" }
var icon: String { "fish.fill" }
var version: String { "0.2.0" }
var version: String { "0.3.0" }
func activate() {
WorkerDebugLog.write("plugin activate")

View File

@ -0,0 +1,106 @@
//
// 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
}
}

View File

@ -31,24 +31,35 @@ final class WorkerNotificationCenter {
let status = settings.authorizationStatus
switch status {
case .authorized, .provisional, .ephemeral:
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = true }
Task { @MainActor in Self.applyAuthorized(true) }
case .denied:
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false }
Task { @MainActor in Self.applyAuthorized(false) }
WorkerDebugLog.write("notifications denied — falling back to in-panel dot")
case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
if let error = error {
WorkerDebugLog.write("notif auth error: \(error)")
}
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = granted }
Task { @MainActor in Self.applyAuthorized(granted) }
WorkerDebugLog.write("notif auth granted=\(granted)")
}
@unknown default:
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false }
Task { @MainActor in Self.applyAuthorized(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
/// (best effort auth status may flip between scheduling and firing).
@discardableResult

View File

@ -0,0 +1,108 @@
//
// 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()
}
}
}

View File

@ -27,6 +27,7 @@ import SwiftUI
private enum K {
static let pomodoroHistory = "pomodoro.history" // [String: Int] (yyyy-MM-dd count)
static let pomodoroQualityHist = "pomodoro.qualityHistory" // [String: [Double]] (yyyy-MM-dd [quality, ...])
static let pomodoroFocusMin = "pomodoro.focusMin" // Int, default 25
static let pomodoroBreakMin = "pomodoro.breakMin" // Int, default 5
static let pomodoroLongBreakMin = "pomodoro.longBreakMin" // Int, default 15
@ -42,7 +43,11 @@ private enum K {
static let sitAccumActive = "sit.accumActive" // Int seconds, persisted active time
static let waterHistory = "water.history" // [String: Int] (date cups)
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 lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day
}
private let suiteName = "com.mioisland.plugin.worker"
@ -101,6 +106,17 @@ final class WorkerStore: ObservableObject {
/// break ends, this resets to 0. Dot indicator in PomodoroView reads
/// this to draw the "🍅🍅" cycle position.
@Published var pomodoroCycleProgress: Int = 0
/// Today's average focus-quality score (0..10). 10 = full focus
/// (no >30s idle gap during any focus phase); 0 = idle the whole
/// time. Updated each time a focus phase ends.
@Published var pomodoroQualityTodayAvg: Double = 0
/// Number of focus phases that contributed to today's avg.
/// Used by PomodoroView to dim the score when n=0.
@Published var pomodoroQualityTodayN: Int = 0
/// Idle seconds accumulated within the active focus phase. A "second
/// of idleness" = a 1Hz tick where SystemIdle.seconds > 30. Used to
/// compute the focus quality on phase end.
private var currentFocusIdleSec: Int = 0
/// Wallclock target. tick computes remaining = endsAt - now. Storing
/// this (instead of decrementing a counter every second) means the
@ -135,10 +151,26 @@ final class WorkerStore: ObservableObject {
/// Water
@Published var waterCupsToday: Int = 0
@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
@Published var clockoutHour: Int = 18
@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)
@Published var weekendDays: Int = 0
@ -204,6 +236,13 @@ final class WorkerStore: ObservableObject {
pomodoroPhase == .focus || pomodoroPhase == .rest {
let remaining = max(0, Int(endsAt.timeIntervalSinceNow))
pomodoroRemaining = remaining
// Accumulate idle time during focus phases so we can score
// quality on phase end. Threshold > 30s drops "I'm thinking
// about the code" false positives but catches "I went to
// Slack / opened YouTube" real distractions.
if pomodoroPhase == .focus && SystemIdle.seconds > 30 {
currentFocusIdleSec += 1
}
if remaining <= 0 {
pomodoroPhaseEnded()
}
@ -245,19 +284,31 @@ final class WorkerStore: ObservableObject {
sitElapsedSec = sitAccumActiveSec
// Threshold notification, deduped by triggerSec.
// Threshold notification fire once, then reset the
// 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
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(
title: "该起来动一下了",
body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。"
)
WorkerDebugLog.write("sit threshold notification fired (\(sitElapsedSec)s)")
}
// 1Hz × 15 ticks of system Morse tone the UN
// 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).
@ -273,12 +324,105 @@ final class WorkerStore: ObservableObject {
// 3. Weekend countdown recompute every tick (cheap).
recomputeWeekend()
// 4. Detect day rollover for pomodoro / water / sit counters.
// 3.5. Water hourly reminder workday window only.
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()
}
/// 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
/// 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() {
if pomodoroPhase == .paused {
pomodoroPhase = pausedPhase
@ -352,6 +496,25 @@ final class WorkerStore: ObservableObject {
pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1)
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.
let isLongBreak = pomodoroCycleProgress >= 4
let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin
@ -406,6 +569,21 @@ final class WorkerStore: ObservableObject {
defaults.set(dict, forKey: K.pomodoroHistory)
}
/// Append `q` to today's quality list and recompute the published
/// average. Stored as `[String: [Double]]` in defaults keyed by
/// the same yyyy-MM-dd date string as pomodoroHistory so a
/// future "today: 6 focuses, avg 8.4/10" reads both in sync.
private func recordPomodoroQuality(_ q: Double) {
let today = Self.dateKey(Date(), calendar: calendar)
var dict = (defaults.dictionary(forKey: K.pomodoroQualityHist) as? [String: [Double]]) ?? [:]
var list = dict[today] ?? []
list.append(q)
dict[today] = list
defaults.set(dict, forKey: K.pomodoroQualityHist)
pomodoroQualityTodayN = list.count
pomodoroQualityTodayAvg = list.reduce(0, +) / Double(list.count)
}
/// Persist the runtime fields that change with start/pause/reset/phase
/// transitions. Keeps a relaunch consistent with what the user saw.
private func persistPomodoroRuntime() {
@ -435,6 +613,9 @@ final class WorkerStore: ObservableObject {
defaults.set(false, forKey: K.sitEnabled)
defaults.set(0, forKey: K.sitAccumActive)
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) {
@ -464,6 +645,16 @@ final class WorkerStore: ObservableObject {
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() {
var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday
@ -480,18 +671,39 @@ final class WorkerStore: ObservableObject {
defaults.set(String(format: "%02d:%02d", h, m), forKey: K.clockoutHHmm)
}
/// Seconds remaining until today's clockout. If it's already past,
/// returns the seconds until tomorrow's clockout (rolls over).
/// Seconds remaining until **today's** clockout. Returns 0 when
/// already past the next day rolls automatically when calendar's
/// 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 {
let now = Date()
var comps = calendar.dateComponents([.year, .month, .day], from: now)
comps.hour = clockoutHour
comps.minute = clockoutMinute
comps.second = 0
var target = calendar.date(from: comps) ?? now
if target <= now {
target = calendar.date(byAdding: .day, value: 1, to: target) ?? target
}
guard let target = calendar.date(from: comps) else { return 0 }
if target <= now { return 0 }
return max(0, Int(target.timeIntervalSince(now)))
}
@ -552,6 +764,11 @@ final class WorkerStore: ObservableObject {
pomodoroTodayCount = pomDict[today] ?? 0
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
waterCupsToday = waterDict[today] ?? 0
// Quality history resets too.
let qDict = (defaults.dictionary(forKey: K.pomodoroQualityHist) as? [String: [Double]]) ?? [:]
let todayQs = qDict[today] ?? []
pomodoroQualityTodayN = todayQs.count
pomodoroQualityTodayAvg = todayQs.isEmpty ? 0 : todayQs.reduce(0, +) / Double(todayQs.count)
// Sitting through midnight is weird start a fresh day's
// active-time count so the badge doesn't show " 1380 ".
sitAccumActiveSec = 0
@ -582,6 +799,12 @@ final class WorkerStore: ObservableObject {
pomodoroTodayCount = pomDict[today] ?? 0
lastSeenDay = today
// Quality history for today (used for the "" stat card).
let qDict = (defaults.dictionary(forKey: K.pomodoroQualityHist) as? [String: [Double]]) ?? [:]
let todayQs = qDict[today] ?? []
pomodoroQualityTodayN = todayQs.count
pomodoroQualityTodayAvg = todayQs.isEmpty ? 0 : todayQs.reduce(0, +) / Double(todayQs.count)
// Pomodoro runtime resume.
let pausedRaw = defaults.string(forKey: K.pomodoroPausedPhase) ?? "focus"
pausedPhase = PomodoroPhase(rawValue: pausedRaw) ?? .focus
@ -649,6 +872,14 @@ final class WorkerStore: ObservableObject {
waterGoal = storedGoal > 0 ? storedGoal : 8
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
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
let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00"

View File

@ -14,26 +14,65 @@ struct ClockoutView: View {
@State private var editMinute: Int = 0
var body: some View {
ZStack(alignment: .top) {
VStack(spacing: 18) {
if store.isWeekend {
weekendHero
weekendTipText
Spacer(minLength: 0)
} else {
statusBadge
countdownDisplay
progressBar
controls
Divider()
.background(WorkerTheme.overlay08)
.padding(.horizontal, 24)
timeRow
tipText
Spacer(minLength: 0)
}
}
.padding(.top, 8)
// Celebration banner overlays the panel for ~5s after the
// user crosses today's clockout boundary. Subtle goldlime
// 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
@ -235,8 +274,74 @@ 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 {
Text("下班时间到时不会响铃 —\n本插件只负责让你看着时间倒计时偷着乐。")
Text("到点会有通知 + 一段 Glass 音效庆祝下班\n每日只触发一次,重启插件不会重复响铃")
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg45)
.padding(.horizontal, 20)

View File

@ -148,7 +148,7 @@ struct ExpandedView: View {
Text(footerLeftText)
}
Spacer()
Text("v0.1 · 本地运行")
Text("v0.3.0 · 本地运行")
}
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg40)

View File

@ -116,12 +116,10 @@ struct PomodoroView: View {
}
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
}
// P0 fix (2026-05-19 review): delegate to store so paused-in-break
// gets the right denominator. Inline switch couldn't see the
// private pausedPhase.
let total = store.pomodoroPhaseTotalSec
guard total > 0 else { return 0 }
let remaining = max(0, store.pomodoroRemaining)
return CGFloat(total - remaining) / CGFloat(total)
@ -203,7 +201,7 @@ struct PomodoroView: View {
// MARK: - Stats
private var statsRow: some View {
HStack(spacing: 10) {
HStack(spacing: 8) {
statCard(
title: "今日番茄",
value: "\(store.pomodoroTodayCount)",
@ -214,8 +212,30 @@ struct PomodoroView: View {
value: "\(store.pomodoroTodayCount * store.pomodoroFocusMin)",
accent: WorkerTheme.lime
)
// Quality score: dimmed (em-dash) when n=0, otherwise the
// running average across today's focus phases.
// 8 / 10 = "1500s focus with 300s idle" solid focus
// 5 / 10 = "half the time I was elsewhere"
// <3 = "you were not really pomodoro-ing"
statCard(
title: "今日均分",
value: store.pomodoroQualityTodayN == 0
? ""
: String(format: "%.1f", store.pomodoroQualityTodayAvg),
accent: store.pomodoroQualityTodayN == 0
? WorkerTheme.fg40
: qualityAccent(store.pomodoroQualityTodayAvg)
)
}
.padding(.horizontal, 16)
.padding(.horizontal, 12)
}
/// Score colour: high = lime, mid = tomato, low = alertRed.
/// Visual sanity-check on glance green is good.
private func qualityAccent(_ score: Double) -> Color {
if score >= 8 { return WorkerTheme.lime }
if score >= 5 { return WorkerTheme.tomato }
return WorkerTheme.alertRed
}
private func statCard(title: String, value: String, accent: Color) -> some View {

View File

@ -11,7 +11,10 @@ struct WaterView: View {
@ObservedObject var store: WorkerStore
var body: some View {
VStack(spacing: 18) {
// No ScrollView panel body sits at ~470pt available and we
// tune everything below to fit: tighter VStack spacing, smaller
// cup hero, toggles on a single row, no tipText.
VStack(spacing: 10) {
statusBadge
cupHero
@ -23,12 +26,73 @@ struct WaterView: View {
.padding(.horizontal, 24)
goalRow
tipText
toggleRow
Spacer(minLength: 0)
}
.padding(.top, 8)
.padding(.top, 6)
}
// 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
@ -58,23 +122,24 @@ struct WaterView: View {
// MARK: - Cup hero (tappable)
private var cupHero: some View {
VStack(spacing: 12) {
VStack(spacing: 8) {
Button(action: { store.waterAddCup() }) {
ZStack {
// Water-fill cup illustration.
// Water-fill cup sized down from 130×160 to fit
// the 380×580 panel without scroll.
cupShape
.frame(width: 130, height: 160)
.frame(width: 100, height: 130)
}
}
.buttonStyle(.plain)
.help("点击杯子记录一杯水")
// Progress dots
HStack(spacing: 6) {
HStack(spacing: 5) {
ForEach(0..<store.waterGoal, id: \.self) { i in
Circle()
.fill(i < store.waterCupsToday ? WorkerTheme.water : WorkerTheme.overlay08)
.frame(width: 9, height: 9)
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(WorkerTheme.overlay12, lineWidth: 0.5)
@ -207,16 +272,6 @@ struct WaterView: View {
.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

View File

@ -11,13 +11,18 @@ struct WeekendView: View {
@ObservedObject var store: WorkerStore
var body: some View {
VStack(spacing: 18) {
ScrollView {
VStack(spacing: 14) {
statusBadge
heroCountdown
partsRow
if store.nextHoliday != nil {
holidayCard
}
Divider()
.background(WorkerTheme.overlay08)
.padding(.horizontal, 24)
@ -27,6 +32,80 @@ struct WeekendView: View {
Spacer(minLength: 0)
}
.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

View File

@ -0,0 +1,294 @@
# 摸鱼侠 — 代码质量 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。
### 问题 1WorkerStore 是 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最大亮点
### 🌟 亮点 1pomodoro 用 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
...
}
```
### 🌟 亮点 2paused 持久化
`WorkerStore.swift:584-624` — paused phase 在 launch 时检测到 `savedPhase == .paused` 跳过 endsAt 计算,直接 resume 到上次 pause 时的 remaining。**正确**。
### 🌟 亮点 3sit 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 minhold steady开会/打电话不归零也不+
```
**比市面上"久坐提醒"app 都准**。
### 🔴 问题 1PomodoroView 进度环 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 RunLoopcb 内 `Task { @MainActor in }` 隔离正确
- `@preconcurrency import UserNotifications` (`NotificationCenter.swift:13`) — 处理 UN API 非全 Sendable
### 🔴 问题 1notifAuthorized 没 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)
### 问题 1notify() 即便授权也每次都 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-valueOK。
### 问题 1历史 dict 增长无 cap
`WorkerStore.swift:403-406` `pomodoroHistory` 每日 key 累积。一年 365 keys五年 1825。**建议**:保留最近 90 天rolloverIfNeeded 时 prune。
### 问题 2lastSeenDay 不持久
`WorkerStore.swift:539 private var lastSeenDay: String = ""`. 重启后 init 时被设成 today (`loadPersisted` 末尾)。day rollover 检测靠这个。跨日运行(不重启)能 catch新一天才启动则 `lastSeenDay = today` 直接rolloverIfNeeded 永不 fireOKdata 也确实新一天 0。**逻辑 work 但脆**。
---
## 六、错误处理 — 6/10
### 问题 1SystemIdle 失败返回 0 → silent 误 grow
`SystemIdle.swift:34`
```swift
return minVal == .greatestFiniteMagnitude ? 0 : minVal
```
`CGEventSource` 全部失败(理论上可能 sandbox 拒绝),返回 0 = "用户刚刚操作了" = sit counter 每秒 +1。**结果**sit counter 误 grow假警报触发。
**修法**:返回 `nil` 或 sentinelstore 里检测无效 idle 数据时 skip tick。
### 问题 2UNUserNotificationCenter.add 错误只日志
`NotificationCenter.swift:67-69` — 错误只写 `WorkerDebugLog`,无 UI 信号。
---
## 七、UI — 7/10
### 强项
- 5 个 view 风格高度一致statusBadge + hero + controls + divider + settings + tipText
- `WorkerTheme` 集中色彩 token`WorkerFormat` 集中时间格式
- 各 view emoji + 中文,跟产品 personality 匹配
### 🔴 问题 1PomodoroView 进度环 paused-in-break 错
(见状态管理问题 1
### 🔴 问题 2ExpandedView 通知状态 dot 永 dim
(见并发问题 1
### 问题 3WaterView 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 machinefocus → rest → focus × 4 → long rest → focusautoLoop| 易(注入 mock Date|
| Pomodoro paused → resume 跨时间正确 | 易 |
| Pomodoro phase end while away`loadPersisted` 路径)| 中 |
| Sit accumulatoridle <60 += 1; 60..300 hold; 300 reset | |
| Sit wakeup gaptickGap > 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 到 storedot 永 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 个 fixturecode quality 能上 9.5/10。
**整体判断**v0.2.0 的代码扎实度足以支撑上架。两个 P0 bugnotif dot + paused fraction一晚上能修完。比看盘侠 v0.3.0 的 Toast bug 更轻 — 后者是 typo 级 ship-blocker。

View File

@ -0,0 +1,161 @@
# 摸鱼侠 — 产品 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 gapMac 睡眠后倒计时不错乱
✅ 4-tomato dot 周期可视化(`PomodoroView.swift:64`
✅ autoLoop toggle — 经典 Pomodoro 节奏,一启动跑一上午
**短板**
- ⚠️ stepperPill 单位 +5/+1/+5 min专注分钟想要 +1 step27 分钟用例)
- ⚠️ 没"中断当前番茄"button — 重置可以替代但语义不同
- ⚠️ "专注时长 = todayCount × focusMin" 算法 simplistic — partial focus 丢失。无所谓,不算精确没事
### 久坐 tab — 9/10 (算法亮点)
**input-idle 算法是核心亮点**`SystemIdle.swift``CGEventSource.secondsSinceLastEventType` 判定真在打字 vs 离开。避免天真版"start timestamp - now"把午休/会议都算成坐着的 trap。
✅ 5min idle 阈值 resetwallclock gap >30s 也 resetsleep/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:009-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 一致 layoutstatusBadge + 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。