mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
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:
parent
2aa68c2427
commit
51cc5bb0cc
86
Sources/engine/Holidays.swift
Normal file
86
Sources/engine/Holidays.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -683,6 +683,13 @@ final class WorkerStore: ObservableObject {
|
||||
/// True iff today is Saturday or Sunday (local time). Used by
|
||||
/// ClockoutView to switch to a "今天不上班" mode without forcing
|
||||
/// 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 {
|
||||
let weekday = calendar.component(.weekday, from: Date())
|
||||
return weekday == 1 || weekday == 7 // Sun = 1, Sat = 7
|
||||
|
||||
@ -11,13 +11,18 @@ struct WeekendView: View {
|
||||
@ObservedObject var store: WorkerStore
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
statusBadge
|
||||
|
||||
heroCountdown
|
||||
|
||||
partsRow
|
||||
|
||||
if store.nextHoliday != nil {
|
||||
holidayCard
|
||||
}
|
||||
|
||||
Divider()
|
||||
.background(WorkerTheme.overlay08)
|
||||
.padding(.horizontal, 24)
|
||||
@ -27,6 +32,80 @@ struct WeekendView: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user