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>
This commit is contained in:
徐翔宇 2026-05-20 09:59:06 +08:00
parent 2aa68c2427
commit 51cc5bb0cc
3 changed files with 182 additions and 10 deletions

View File

@ -0,0 +1,86 @@
//
// 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).
//
// 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.
//
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. e.g.
/// = 8 covers 10/01-10/08 (when paired with in 2026).
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 estimated from typical State Council pattern.
/// **Verify against the official announcement.**
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),
]
/// 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

@ -683,6 +683,13 @@ final class WorkerStore: ObservableObject {
/// True iff today is Saturday or Sunday (local time). Used by /// True iff today is Saturday or Sunday (local time). Used by
/// ClockoutView to switch to a "" mode without forcing /// ClockoutView to switch to a "" mode without forcing
/// the user to fiddle with their clockout time on the weekend. /// 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 { var isWeekend: Bool {
let weekday = calendar.component(.weekday, from: Date()) let weekday = calendar.component(.weekday, from: Date())
return weekday == 1 || weekday == 7 // Sun = 1, Sat = 7 return weekday == 1 || weekday == 7 // Sun = 1, Sat = 7

View File

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