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>
This commit is contained in:
徐翔宇 2026-05-20 10:12:40 +08:00
parent 51cc5bb0cc
commit dc0d22700c
5 changed files with 52 additions and 31 deletions

View File

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

View File

@ -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.2.0" } var version: String { "0.3.0" }
func activate() { func activate() {
WorkerDebugLog.write("plugin activate") WorkerDebugLog.write("plugin activate")

View File

@ -2,15 +2,26 @@
// Holidays.swift // Holidays.swift
// plugin v0.3.0 // plugin v0.3.0
// //
// China State Council statutory holidays 2026. Estimated from prior- // China State Council statutory holidays for 2026. Calibrated to the
// year patterns ( + ) exact dates and // + + typical pattern; **the official
// adjusted-workday days may shift when the State Council announces // announcement (usually released Nov of prior year)** is the
// each year's calendar (typically Q4 of the prior year). // 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 // Data structure is a flat list rather than a JSON resource so the
// plugin bundle stays single-binary and dependency-free. To update: // plugin bundle stays single-binary and dependency-free.
// edit this file when the next year's official
// announcement publishes.
// //
import Foundation import Foundation
@ -20,8 +31,7 @@ struct Holiday {
let name: String let name: String
/// First day of the holiday window (yyyy-MM-dd, Asia/Shanghai). /// First day of the holiday window (yyyy-MM-dd, Asia/Shanghai).
let startDate: String let startDate: String
/// Total consecutive days off, including the trigger day. e.g. /// Total consecutive days off, including the trigger day.
/// = 8 covers 10/01-10/08 (when paired with in 2026).
let days: Int let days: Int
} }
@ -37,18 +47,28 @@ struct UpcomingHoliday {
} }
enum HolidayDatabase { enum HolidayDatabase {
/// 2026 estimated from typical State Council pattern. /// 2026 best estimate per typical State Council pattern.
/// **Verify against the official announcement.** /// 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] = [ static let entries2026: [Holiday] = [
Holiday(name: "元旦", startDate: "2026-01-01", days: 1), // 2024 1
Holiday(name: "春节", startDate: "2026-02-17", days: 7), Holiday(name: "元旦", startDate: "2026-01-01", days: 1),
Holiday(name: "清明", startDate: "2026-04-04", days: 3), // 02-16 ~ 02-22 7 02-14 + 02-28
Holiday(name: "五一", startDate: "2026-05-01", days: 5), Holiday(name: "春节", startDate: "2026-02-16", days: 7),
Holiday(name: "端午", startDate: "2026-06-19", days: 3), // 04-05 04-04 ~ 04-06 3
Holiday(name: "中秋·国庆", startDate: "2026-10-01", days: 8), Holiday(name: "清明", startDate: "2026-04-04", days: 3),
// Roll-over to early 2027 so late-Dec users still see a next // 05-01 ~ 05-05 5 04-26
// holiday after the 2026 calendar runs out. Holiday(name: "劳动节", startDate: "2026-05-01", days: 5),
Holiday(name: "元旦", startDate: "2027-01-01", days: 3), // 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. /// Returns the soonest upcoming or currently-ongoing holiday.

View File

@ -45,7 +45,7 @@ private enum K {
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 waterAutoFromPomo = "water.autoFromPomo" // Bool, default true
static let waterHourlyReminder = "water.hourlyReminder" // Bool, default true static let waterHourlyReminder = "water.hourlyReminder" // Bool, default true
static let lastWaterReminderHr = "water.lastReminderHour" // Int, hour 0-23 last fired (per day) static let 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 static let lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day
} }
@ -357,12 +357,13 @@ final class WorkerStore: ObservableObject {
guard (9...18).contains(hour) else { return } guard (9...18).contains(hour) else { return }
// Already reached goal no nag. // Already reached goal no nag.
guard waterCupsToday < waterGoal else { return } guard waterCupsToday < waterGoal else { return }
let lastFiredHour = defaults.integer(forKey: K.lastWaterReminderHr) // Scope dedupe by date+hour so yesterday's 17:00 doesn't block
// Sentinel: 0 means "never fired" first run at any hour >= 9 // today's 17:00. Prior int-only key blocked the same hour
// will pass the != check. We accept one wasted re-fire on the // forever within calendar 24h cycles.
// boundary case where last == hour 0 from a fresh install. let key = "\(Self.dateKey(now, calendar: calendar)) \(String(format: "%02d", hour))"
guard lastFiredHour != hour else { return } let lastFiredKey = defaults.string(forKey: K.lastWaterReminderHr) ?? ""
defaults.set(hour, forKey: K.lastWaterReminderHr) guard lastFiredKey != key else { return }
defaults.set(key, forKey: K.lastWaterReminderHr)
let remaining = waterGoal - waterCupsToday let remaining = waterGoal - waterCupsToday
WorkerNotificationCenter.shared.notify( WorkerNotificationCenter.shared.notify(
title: "💧 喝水时间到", title: "💧 喝水时间到",

View File

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