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>
<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

@ -2,15 +2,26 @@
// Holidays.swift
// plugin v0.3.0
//
// China State Council statutory holidays 2026. Estimated from prior-
// year patterns ( + ) exact dates and
// adjusted-workday days may shift when the State Council announces
// each year's calendar (typically Q4 of the prior year).
// 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. To update:
// edit this file when the next year's official
// announcement publishes.
// plugin bundle stays single-binary and dependency-free.
//
import Foundation
@ -20,8 +31,7 @@ struct Holiday {
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. e.g.
/// = 8 covers 10/01-10/08 (when paired with in 2026).
/// Total consecutive days off, including the trigger day.
let days: Int
}
@ -37,18 +47,28 @@ struct UpcomingHoliday {
}
enum HolidayDatabase {
/// 2026 estimated from typical State Council pattern.
/// **Verify against the official announcement.**
/// 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] = [
Holiday(name: "元旦", startDate: "2026-01-01", days: 1),
Holiday(name: "春节", startDate: "2026-02-17", days: 7),
Holiday(name: "清明", startDate: "2026-04-04", days: 3),
Holiday(name: "五一", startDate: "2026-05-01", days: 5),
Holiday(name: "端午", startDate: "2026-06-19", days: 3),
Holiday(name: "中秋·国庆", startDate: "2026-10-01", days: 8),
// Roll-over to early 2027 so late-Dec users still see a next
// holiday after the 2026 calendar runs out.
Holiday(name: "元旦", startDate: "2027-01-01", days: 3),
// 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.

View File

@ -45,7 +45,7 @@ private enum K {
static let waterGoal = "water.goal" // Int, default 8
static let waterAutoFromPomo = "water.autoFromPomo" // Bool, default true
static let waterHourlyReminder = "water.hourlyReminder" // Bool, default true
static let lastWaterReminderHr = "water.lastReminderHour" // Int, hour 0-23 last fired (per day)
static let 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
}
@ -357,12 +357,13 @@ final class WorkerStore: ObservableObject {
guard (9...18).contains(hour) else { return }
// Already reached goal no nag.
guard waterCupsToday < waterGoal else { return }
let lastFiredHour = defaults.integer(forKey: K.lastWaterReminderHr)
// Sentinel: 0 means "never fired" first run at any hour >= 9
// will pass the != check. We accept one wasted re-fire on the
// boundary case where last == hour 0 from a fresh install.
guard lastFiredHour != hour else { return }
defaults.set(hour, forKey: K.lastWaterReminderHr)
// 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: "💧 喝水时间到",

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)