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>
This commit is contained in:
徐翔宇 2026-05-20 09:48:21 +08:00
parent c1747dc1ce
commit d4fea96d17
3 changed files with 128 additions and 20 deletions

View File

@ -50,6 +50,25 @@ final class SoundPlayer {
} }
} }
/// Clockout celebration tone Glass (1.0s sustained "" tone)
/// played 3 times with 0.5s gap. Distinct from sit Morse so the
/// user instantly knows "" not "".
func playClockoutCelebration() {
stop()
let glassURL = URL(fileURLWithPath: "/System/Library/Sounds/Glass.aiff")
// 3 chimes at 0s, 0.6s, 1.2s shorter cadence than sit (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: glassURL, byReference: true) {
s.play()
}
}
}
}
/// Cancel any in-flight alert. Called by WorkerStore.sitStop() so /// Cancel any in-flight alert. Called by WorkerStore.sitStop() so
/// "user stopped sit monitoring" silences the nag immediately. /// "user stopped sit monitoring" silences the nag immediately.
func stop() { func stop() {

View File

@ -43,6 +43,7 @@ private enum K {
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 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"
@ -139,6 +140,15 @@ final class WorkerStore: ObservableObject {
/// 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
@ -285,10 +295,43 @@ final class WorkerStore: ObservableObject {
// 3. Weekend countdown recompute every tick (cheap). // 3. Weekend countdown recompute every tick (cheap).
recomputeWeekend() recomputeWeekend()
// 4. Detect day rollover for pomodoro / water / sit counters. // 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()
} }
/// 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 /// Total duration (seconds) of the active phase. Used as denominator
@ -523,18 +566,24 @@ 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. If it's already past, /// Seconds remaining until **today's** clockout. Returns 0 when
/// returns the seconds until tomorrow's clockout (rolls over). /// 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.
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
var target = calendar.date(from: comps) ?? now guard let target = calendar.date(from: comps) else { return 0 }
if target <= now { if target <= now { return 0 }
target = calendar.date(byAdding: .day, value: 1, to: target) ?? target
}
return max(0, Int(target.timeIntervalSince(now))) return max(0, Int(target.timeIntervalSince(now)))
} }

View File

@ -14,26 +14,66 @@ struct ClockoutView: View {
@State private var editMinute: Int = 0 @State private var editMinute: Int = 0
var body: some View { var body: some View {
VStack(spacing: 18) { ZStack(alignment: .top) {
statusBadge VStack(spacing: 18) {
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)
// 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
)
)
}
} }
.padding(.top, 8) .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
@ -236,7 +276,7 @@ struct ClockoutView: View {
} }
private var tipText: some View { private var tipText: some View {
Text("下班时间到时不会响铃 —\n本插件只负责让你看着时间倒计时偷着乐") Text("到点会有通知 + 一段 Glass 音效庆祝下班\n每日只触发一次,重启插件不会重复响铃")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(WorkerTheme.fg45) .foregroundColor(WorkerTheme.fg45)
.padding(.horizontal, 20) .padding(.horizontal, 20)