diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift index 2f9967e..f7df55d 100644 --- a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -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. """# } diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 0c6005a..663a2f1 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -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 للسنة. يُعرض التنسيق غير الصالح مؤقتًا كشهر/يوم. 按住 ⌘ 拖动菜单栏图标可调整位置。 اضغط على ⌘ أثناء سحب أيقونة شريط القوائم لتغيير موضعها. +续 متابعة diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 6aa0eb0..198ae64 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 81f768a..4235f50 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index a3de338..7ea0a88 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 7756769..7a0ac18 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -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 を使ってください。無効な書式は一時的に月/日で表示します。 按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ を押しながらメニューバーのアイコンをドラッグすると移動できます。 +续 続き diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 56a5201..d63bc84 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -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를 사용하세요. 잘못된 형식은 임시로 월/일로 표시됩니다. 按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ 키를 누른 채 메뉴 막대 아이콘을 드래그하여 위치를 조정하세요. +续 계속 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index e35b4cd..3f44ec4 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -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。格式無效時暫以月/日顯示。 按住 ⌘ 拖动菜单栏图标可调整位置。 按住 ⌘ 拖移選單列圖示可調整位置。 +续 續 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift index 06c8fce..f00908a 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift @@ -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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift index 62f1261..5b54036 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift @@ -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) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift index 7337ff2..95ed6e0 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -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 = { let data = Array(LegalHoliday.DATA.utf8) return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift index 57d311d..32f1e92 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift @@ -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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift index 14cc449..433a4ab 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -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() } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift index de12d87..737429f 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -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)