68fed087-9049-446f-bfc5-3f8.../Sources/engine/Holidays.swift
徐翔宇 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

107 lines
4.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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