fix(calendar): 修复日程刷新并优化假期计算

This commit is contained in:
ysicing committed 2026-09-28 22:54:18 +08:00
1 parent ffb2bba651
commit cb758c134c
14 files changed
+102 -46

No files matched your search

@@ -150,10 +150,6 @@ extension Translations {
允许访问 Allow access
打开系统设置 Open System Settings
读取失败,请重试 Could not load. Try again.
节假日数据 Holiday data
最新数据年份:{} Latest data year: {}
假日数据随应用更新;未收录年份不推断调休。 Holiday data is updated with the app. Makeup workdays are not inferred for uncovered years.
查看更新 View updates
日程尚未授权 Calendar access not granted
提醒事项尚未授权 Reminders access not granted
无标题 Untitled
@@ -169,5 +165,6 @@ extension Translations {
请假日期:{} Leave dates: {}
按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 Based on a two-day weekend and published Chinese makeup workdays. Leave requires your employer’s approval.
有日程或提醒 Has events or reminders
续 cont.
"""#
}
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 السماح بالوصول
打开系统设置 فتح إعدادات النظام
读取失败,请重试 تعذر التحميل. أعد المحاولة.
节假日数据 بيانات العطلات
最新数据年份:{} أحدث سنة للبيانات: {}
假日数据随应用更新;未收录年份不推断调休。 تُحدَّث بيانات العطلات مع التطبيق. لا تُخمَّن أيام العمل التعويضية للسنوات غير المشمولة.
查看更新 عرض التحديثات
日程尚未授权 لم يُمنح الوصول إلى التقويم
提醒事项尚未授权 لم يُمنح الوصول إلى التذكيرات
无标题 بلا عنوان
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 أمثلة: yyyy-MM-dd، M/d، dd/MM. السنة اختيارية.
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 أدرج M للشهر وd لليوم، واستخدم y للسنة. يُعرض التنسيق غير الصالح مؤقتًا كشهر/يوم.
按住 ⌘ 拖动菜单栏图标可调整位置。 اضغط على ⌘ أثناء سحب أيقونة شريط القوائم لتغيير موضعها.
续 متابعة
1 # Copyright (C) 2026 ysicing
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1587 日程尚未授权
1588 提醒事项尚未授权
1589 无标题
1605 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1606 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1607 按住 ⌘ 拖动菜单栏图标可调整位置。
1608 续
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 Zugriff erlauben
打开系统设置 Systemeinstellungen öffnen
读取失败,请重试 Laden fehlgeschlagen. Erneut versuchen.
节假日数据 Feiertagsdaten
最新数据年份:{} Aktuellstes Datenjahr: {}
假日数据随应用更新;未收录年份不推断调休。 Feiertagsdaten werden mit der App aktualisiert. Ausgleichsarbeitstage werden für fehlende Jahre nicht geschätzt.
查看更新 Updates ansehen
日程尚未授权 Kalenderzugriff nicht erlaubt
提醒事项尚未授权 Erinnerungszugriff nicht erlaubt
无标题 Ohne Titel
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Beispiele: yyyy-MM-dd, M/d, dd/MM. Das Jahr ist optional.
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 M für Monat und d für Tag angeben; y für das Jahr verwenden. Ungültige Formate erscheinen vorübergehend als Monat/Tag.
按住 ⌘ 拖动菜单栏图标可调整位置。 Zum Verschieben das Menüleistensymbol bei gedrückter ⌘-Taste ziehen.
续 Forts.
1 # Copyright (C) 2026 ysicing
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1587 日程尚未授权
1588 提醒事项尚未授权
1589 无标题
1605 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1606 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1607 按住 ⌘ 拖动菜单栏图标可调整位置。
1608 续
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 Permitir acceso
打开系统设置 Abrir Ajustes del Sistema
读取失败,请重试 No se pudo cargar. Inténtalo de nuevo.
节假日数据 Datos de festivos
最新数据年份:{} Último año de datos: {}
假日数据随应用更新;未收录年份不推断调休。 Los datos se actualizan con la app. No se deducen días laborables compensatorios para años sin datos.
查看更新 Ver actualizaciones
日程尚未授权 Acceso al calendario no concedido
提醒事项尚未授权 Acceso a recordatorios no concedido
无标题 Sin título
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Ejemplos: yyyy-MM-dd, M/d, dd/MM. El año es opcional.
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Incluye M para el mes y d para el día; usa y para el año. Los formatos inválidos se muestran temporalmente como mes/día.
按住 ⌘ 拖动菜单栏图标可调整位置。 Mantén pulsada ⌘ y arrastra el icono de la barra de menús para moverlo.
续 cont.
1 # Copyright (C) 2026 ysicing
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1587 日程尚未授权
1588 提醒事项尚未授权
1589 无标题
1605 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1606 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1607 按住 ⌘ 拖动菜单栏图标可调整位置。
1608 续
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 Autoriser l’accès
打开系统设置 Ouvrir Réglages Système
读取失败,请重试 Échec du chargement. Réessayez.
节假日数据 Données des jours fériés
最新数据年份:{} Année des dernières données : {}
假日数据随应用更新;未收录年份不推断调休。 Les données suivent les mises à jour de l’app. Aucun jour de travail compensatoire n’est déduit pour les années manquantes.
查看更新 Voir les mises à jour
日程尚未授权 Accès au calendrier non autorisé
提醒事项尚未授权 Accès aux rappels non autorisé
无标题 Sans titre
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Exemples : yyyy-MM-dd, M/d, dd/MM. L’année est facultative.
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Incluez M pour le mois et d pour le jour ; utilisez y pour l’année. Un format invalide affiche provisoirement mois/jour.
按住 ⌘ 拖动菜单栏图标可调整位置。 Maintenez ⌘ en faisant glisser l’icône de la barre des menus pour la déplacer.
续 suite
1 # Copyright (C) 2026 ysicing
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1587 日程尚未授权
1588 提醒事项尚未授权
1589 无标题
1605 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1606 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1607 按住 ⌘ 拖动菜单栏图标可调整位置。
1608 续
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 アクセスを許可
打开系统设置 システム設定を開く
读取失败,请重试 読み込めませんでした。再試行してください。
节假日数据 祝日データ
最新数据年份:{} 最新データの年:{}
假日数据随应用更新;未收录年份不推断调休。 祝日データはアプリの更新で配信します。未収録の年の振替出勤日は推測しません。
查看更新 更新を確認
日程尚未授权 カレンダーへのアクセス未許可
提醒事项尚未授权 リマインダーへのアクセス未許可
无标题 名称未設定
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 例:yyyy-MM-dd、M月d日、dd/MM。年は省略できます。
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 月の M と日の d を含め、年には y を使ってください。無効な書式は一時的に月/日で表示します。
按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ を押しながらメニューバーのアイコンをドラッグすると移動できます。
续 続き
1 # Copyright (C) 2026 ysicing
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1587 日程尚未授权
1588 提醒事项尚未授权
1589 无标题
1605 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1606 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1607 按住 ⌘ 拖动菜单栏图标可调整位置。
1608 续
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 접근 허용
打开系统设置 시스템 설정 열기
读取失败,请重试 불러오지 못했습니다. 다시 시도하세요.
节假日数据 공휴일 데이터
最新数据年份:{} 최신 데이터 연도: {}
假日数据随应用更新;未收录年份不推断调休。 공휴일 데이터는 앱과 함께 업데이트됩니다. 미수록 연도의 대체 근무일은 추정하지 않습니다.
查看更新 업데이트 보기
日程尚未授权 캘린더 접근이 허용되지 않음
提醒事项尚未授权 미리 알림 접근이 허용되지 않음
无标题 제목 없음
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 예: yyyy-MM-dd, M/d, dd/MM. 연도는 생략할 수 있습니다.
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 월 M과 일 d를 포함하고 연도에는 y를 사용하세요. 잘못된 형식은 임시로 월/일로 표시됩니다.
按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ 키를 누른 채 메뉴 막대 아이콘을 드래그하여 위치를 조정하세요.
续 계속
1 # Copyright (C) 2026 ysicing
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1587 日程尚未授权
1588 提醒事项尚未授权
1589 无标题
1605 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1606 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1607 按住 ⌘ 拖动菜单栏图标可调整位置。
1608 续
@@ -1585,10 +1585,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
允许访问 允許存取
打开系统设置 開啟系統設定
读取失败,请重试 讀取失敗,請重試
节假日数据 節假日資料
最新数据年份:{} 最新資料年份:{}
假日数据随应用更新;未收录年份不推断调休。 假日資料隨應用程式更新;未收錄年份不推斷調休。
查看更新 查看更新
日程尚未授权 行程尚未授權
提醒事项尚未授权 提醒事項尚未授權
无标题 無標題
@@ -1610,3 +1606,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 請包含月份 M 和日期 d;年份使用 y。格式無效時暫以月/日顯示。
按住 ⌘ 拖动菜单栏图标可调整位置。 按住 ⌘ 拖移選單列圖示可調整位置。
续 續
1 # Copyright (c) 2026 GiantAccel, LLC
1585 允许访问
1586 打开系统设置
1587 读取失败,请重试
节假日数据
最新数据年份:{}
假日数据随应用更新;未收录年份不推断调休。
查看更新
1588 日程尚未授权
1589 提醒事项尚未授权
1590 无标题
1606 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1607 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1608 按住 ⌘ 拖动菜单栏图标可调整位置。
1609 续
@@ -20,6 +20,13 @@ struct CalendarAgendaItem: Identifiable, Sendable {
let isAllDay: Bool
let isReminder: Bool
/// 在这一天显示的时间:跨天日程从前一天延续过来时显示“续”,而不是前一天的开始时间
func timeLabel(on date: Date, calendar: Calendar) -> String {
if isAllDay { return tr("全天") }
if let day = calendar.dateInterval(of: .day, for: date), start < day.start { return tr("续") }
return start.formatted(.dateTime.hour().minute().locale(L10n.locale))
}
/// 全天及跨天事件的结束时间是开区间,不能在次日多画一个日程标记。
func occurs(on date: Date, calendar: Calendar) -> Bool {
guard let day = calendar.dateInterval(of: .day, for: date) else { return false }
@@ -40,18 +47,32 @@ struct CalendarAgendaQuery: Equatable, Sendable {
/// EventKit 对象留在工作 actor 内;界面只接收值类型,不在主线程同步查询日程。
private actor CalendarAgendaReader {
private lazy var store = EKEventStore()
private var pending: [UUID: (Any, CheckedContinuation<[CalendarAgendaItem]?, Never>)] = [:]
private var cachedStore: EKEventStore?
private var storeAccess: (EKAuthorizationStatus, EKAuthorizationStatus)?
private var pending: [UUID: (EKEventStore, Any, CheckedContinuation<[CalendarAgendaItem]?, Never>)] = [:]
/// 授权变化后换用新的 store:授权前创建的实例看不到之后才获准访问的日历或提醒事项。
/// 每次使用前按当前授权判断,不依赖调用方的通知顺序。
private var store: EKEventStore {
let access = (EKEventStore.authorizationStatus(for: .event), EKEventStore.authorizationStatus(for: .reminder))
if let cachedStore, let storeAccess, storeAccess == access { return cachedStore }
let fresh = EKEventStore()
cachedStore = fresh
storeAccess = access
return fresh
}
func sources(reminders: Bool) -> [CalendarAgendaSource] {
let type: EKEntityType = reminders ? .reminder : .event
guard EKEventStore.authorizationStatus(for: type) == .fullAccess else { return [] }
let store = self.store
return store.calendars(for: type).map { .init(id: $0.calendarIdentifier, title: $0.title) }
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
func load(_ query: CalendarAgendaQuery) async -> [CalendarAgendaItem]? {
guard !Task.isCancelled else { return nil }
let store = self.store
var result: [CalendarAgendaItem] = []
if query.events && EKEventStore.authorizationStatus(for: .event) == .fullAccess {
let calendars = store.calendars(for: .event).filter { query.eventIDs?.contains($0.calendarIdentifier) ?? true }
@@ -68,7 +89,7 @@ private actor CalendarAgendaReader {
if query.reminders && EKEventStore.authorizationStatus(for: .reminder) == .fullAccess {
let calendars = store.calendars(for: .reminder).filter { query.reminderIDs?.contains($0.calendarIdentifier) ?? true }
if !calendars.isEmpty {
guard let reminders = await reminders(query, calendars: calendars) else { return nil }
guard let reminders = await reminders(query, calendars: calendars, store: store) else { return nil }
result += reminders
}
}
@@ -78,7 +99,7 @@ private actor CalendarAgendaReader {
}
}
private func reminders(_ query: CalendarAgendaQuery, calendars: [EKCalendar]) async -> [CalendarAgendaItem]? {
private func reminders(_ query: CalendarAgendaQuery, calendars: [EKCalendar], store: EKEventStore) async -> [CalendarAgendaItem]? {
let id = UUID()
let predicate = store.predicateForIncompleteReminders(withDueDateStarting: query.start, ending: query.end, calendars: calendars)
return await withTaskCancellationHandler {
@@ -100,7 +121,7 @@ private actor CalendarAgendaReader {
let reader = self
Task { await reader?.finish(id, items: items) }
}
pending[id] = (token, continuation)
pending[id] = (store, token, continuation)
}
} onCancel: {
Task { await self.cancel(id) }
@@ -108,11 +129,11 @@ private actor CalendarAgendaReader {
}
private func finish(_ id: UUID, items: [CalendarAgendaItem]?) {
pending.removeValue(forKey: id)?.1.resume(returning: items)
pending.removeValue(forKey: id)?.2.resume(returning: items)
}
private func cancel(_ id: UUID) {
guard let (token, continuation) = pending.removeValue(forKey: id) else { return }
guard let (store, token, continuation) = pending.removeValue(forKey: id) else { return }
store.cancelFetchRequest(token)
continuation.resume(returning: nil)
}
@@ -133,9 +154,19 @@ final class CalendarAgendaController {
@ObservationIgnored private let reader = CalendarAgendaReader()
@ObservationIgnored private var generation = UUID()
/// 打开面板、应用激活时调用;授权确实变化才重新查询,避免每次打开都取消刚开始的查询再重来
func refreshAuthorization() {
eventsAccess = EKEventStore.authorizationStatus(for: .event)
remindersAccess = EKEventStore.authorizationStatus(for: .reminder)
let events = EKEventStore.authorizationStatus(for: .event)
let reminders = EKEventStore.authorizationStatus(for: .reminder)
guard events != eventsAccess || reminders != remindersAccess else { return }
eventsAccess = events
remindersAccess = reminders
revision += 1
}
/// 日历或提醒事项内容变化(EKEventStoreChanged)时重新查询
func storeChanged() {
refreshAuthorization()
revision += 1
}
@@ -164,9 +195,9 @@ final class CalendarAgendaController {
func load(_ query: CalendarAgendaQuery) async {
let current = UUID()
generation = current
items = []
failed = false
guard query.events || query.reminders else { isLoading = false; return }
// 新结果返回前保留旧列表,刷新时日程和日期圆点不会先消失再出现;按日期过滤,跨月也不会显示错位
guard query.events || query.reminders else { items = []; isLoading = false; return }
isLoading = true
let result = await reader.load(query)
guard generation == current, !Task.isCancelled else { return }
@@ -42,7 +42,7 @@ struct CalendarAgendaView: View {
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.source).font(.caption).foregroundStyle(.secondary)
}
Text(item.isAllDay ? tr("全天") : item.start.formatted(.dateTime.hour().minute().locale(L10n.locale)))
Text(item.timeLabel(on: day.date, calendar: CalendarEngine.gregorian()))
.font(.caption).foregroundStyle(.secondary)
}
}
@@ -177,7 +177,6 @@ enum CalendarEngine {
/// 年份覆盖来自固定版本的数据本身;未收录年份不能把 nil 解读为“无需调休”。
static func hasHolidayData(year: Int) -> Bool { holidayYears.contains(year) }
static var latestHolidayYear: Int? { holidayYears.max() }
private static let holidayYears: Set<Int> = {
let data = Array(LegalHoliday.DATA.utf8)
return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in
@@ -66,9 +66,20 @@ extension CalendarHolidayPlan {
}
extension CalendarEngine {
/// 同一天、同一时区的结果不变;面板每次测量都会新建视图,缓存避免在主线程上反复逐日查询约 390 天
private static var holidayPlanCache: (key: String, plan: CalendarHolidayPlan?)?
static func holidayPlan(from date: Date, timeZone: TimeZone = .autoupdatingCurrent) -> CalendarHolidayPlan? {
let calendar = gregorian(timeZone: timeZone)
let today = calendar.startOfDay(for: date)
let key = "\(timeZone.identifier)|\(today.timeIntervalSince1970)"
if let cached = holidayPlanCache, cached.key == key { return cached.plan }
let plan = computeHolidayPlan(today: today, calendar: calendar)
holidayPlanCache = (key, plan)
return plan
}
private static func computeHolidayPlan(today: Date, calendar: Calendar) -> CalendarHolidayPlan? {
var days: [CalendarWorkday] = []
for offset in -21...370 {
guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
@@ -126,7 +126,7 @@ struct CalendarPopover: View {
}
.onDisappear { if !isSnapshot { model.calendarAgenda.clear() } }
.onReceive(NotificationCenter.default.publisher(for: .EKEventStoreChanged)) { _ in
if !isSnapshot { model.calendarAgenda.refreshAuthorization() }
if !isSnapshot { model.calendarAgenda.storeChanged() }
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
if !isSnapshot { model.calendarAgenda.refreshAuthorization() }
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Localization
import Testing
@testable import XStatsUI
@@ -115,6 +116,44 @@ struct CalendarEnhancementTests {
#expect(reminder.occurs(on: next, calendar: calendar))
}
@Test func overnightEventShowsContinuationInsteadOfYesterdaysStartTime() throws {
let calendar = CalendarEngine.gregorian(timeZone: zone)
let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28, hour: 22)))
let nextDay = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 29)))
let night = CalendarAgendaItem(id: "night", title: "", source: "", start: start,
end: start.addingTimeInterval(4 * 3600), isAllDay: false, isReminder: false)
let first = night.timeLabel(on: start, calendar: calendar)
#expect(first != tr("续") && first != tr("全天"), "start day shows the start time: \(first)")
#expect(night.timeLabel(on: nextDay, calendar: calendar) == tr("续"))
let allDay = CalendarAgendaItem(id: "all", title: "", source: "", start: nextDay,
end: nextDay.addingTimeInterval(86_400), isAllDay: true, isReminder: false)
#expect(allDay.timeLabel(on: nextDay, calendar: calendar) == tr("全天"))
}
@Test func refreshingUnchangedAuthorizationDoesNotRestartTheQuery() {
let agenda = CalendarAgendaController()
let before = agenda.revision
// 打开面板、应用激活都会调用;授权没变时不能改动查询的 id,否则刚开始的查询会被取消重来
agenda.refreshAuthorization()
agenda.refreshAuthorization()
#expect(agenda.revision == before)
agenda.storeChanged()
#expect(agenda.revision == before + 1, "calendar content changes must trigger a reload")
}
@Test func holidayPlanCacheStillFollowsDayAndTimeZone() throws {
let before = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone))
let first = try #require(CalendarEngine.holidayPlan(from: before.date, timeZone: zone))
let repeated = try #require(CalendarEngine.holidayPlan(from: before.date.addingTimeInterval(3600), timeZone: zone))
#expect(repeated.daysUntil == first.daysUntil && repeated.start == first.start)
// 换日或换时区都不能复用上一次的结果
let later = try #require(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone))
#expect(try #require(CalendarEngine.holidayPlan(from: later.date, timeZone: zone)).daysUntil == first.daysUntil - 1)
let utc = TimeZone(secondsFromGMT: 0)!
let utcPlan = try #require(CalendarEngine.holidayPlan(from: before.date, timeZone: utc))
#expect(utcPlan.start != first.start, "a different time zone must be recomputed")
}
@Test func eventDayMatchingRespectsDSTInsteadOfFixed24Hours() throws {
let zone = try #require(TimeZone(identifier: "America/Los_Angeles"))
let calendar = CalendarEngine.gregorian(timeZone: zone)