diff --git a/Sources/engine/Holidays.swift b/Sources/engine/Holidays.swift new file mode 100644 index 0000000..5efe409 --- /dev/null +++ b/Sources/engine/Holidays.swift @@ -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 + } +} diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift index ca763cf..a54e8f4 100644 --- a/Sources/engine/WorkerStore.swift +++ b/Sources/engine/WorkerStore.swift @@ -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 diff --git a/Sources/ui/WeekendView.swift b/Sources/ui/WeekendView.swift index 983760a..a3a832b 100644 --- a/Sources/ui/WeekendView.swift +++ b/Sources/ui/WeekendView.swift @@ -11,22 +11,101 @@ struct WeekendView: View { @ObservedObject var store: WorkerStore var body: some View { - VStack(spacing: 18) { - statusBadge + ScrollView { + VStack(spacing: 14) { + statusBadge - heroCountdown + heroCountdown - partsRow + partsRow - Divider() - .background(WorkerTheme.overlay08) - .padding(.horizontal, 24) + if store.nextHoliday != nil { + holidayCard + } - 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