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
|
/// 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
|
||||||
|
|||||||
@ -11,22 +11,101 @@ struct WeekendView: View {
|
|||||||
@ObservedObject var store: WorkerStore
|
@ObservedObject var store: WorkerStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
ScrollView {
|
||||||
statusBadge
|
VStack(spacing: 14) {
|
||||||
|
statusBadge
|
||||||
|
|
||||||
heroCountdown
|
heroCountdown
|
||||||
|
|
||||||
partsRow
|
partsRow
|
||||||
|
|
||||||
Divider()
|
if store.nextHoliday != nil {
|
||||||
.background(WorkerTheme.overlay08)
|
holidayCard
|
||||||
.padding(.horizontal, 24)
|
}
|
||||||
|
|
||||||
quoteCard
|
Divider()
|
||||||
|
.background(WorkerTheme.overlay08)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
quoteCard
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user