From 222b2baa895a48c3b169cfdabdac832e5f062f2a Mon Sep 17 00:00:00 2001 From: ysicing Date: Wed, 30 Sep 2026 20:27:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(calendar):=20=E4=BF=9D=E7=95=99=E5=86=9C?= =?UTF-8?q?=E5=8E=86=E9=80=89=E6=8B=A9=E5=B9=B6=E5=AE=8C=E5=96=84=E5=91=A8?= =?UTF-8?q?=E8=B5=B7=E5=A7=8B=E6=97=A5=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 5 +- DEVELOPMENT.md | 4 +- .../Localization/CalendarTranslations.swift | 2 +- .../Sources/Localization/Resources/ar.tsv | 2 +- .../Sources/Localization/Resources/de.tsv | 2 +- .../Sources/Localization/Resources/es.tsv | 2 +- .../Sources/Localization/Resources/fr.tsv | 2 +- .../Sources/Localization/Resources/ja.tsv | 2 +- .../Sources/Localization/Resources/ko.tsv | 2 +- .../Localization/Resources/zh-Hant.tsv | 2 +- .../Sources/WidgetData/WidgetSnapshot.swift | 2 +- .../XStatsUI/Calendar/CalendarEngine.swift | 2 +- .../Calendar/CalendarPreferences.swift | 13 +-- .../XStatsUI/Calendar/CalendarSettings.swift | 17 ++-- .../Sources/XStatsUI/State/AppSettings.swift | 10 ++- .../XStatsUI/State/SettingsDocument.swift | 2 +- .../WidgetDataTests/WidgetSnapshotTests.swift | 9 ++ .../CalendarEnhancementTests.swift | 86 ++++++++++++++----- Widget/CalendarWidget.swift | 9 +- 19 files changed, 116 insertions(+), 59 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4afdc0d..704ab2f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -180,7 +180,10 @@ not 24-hour intervals. Tibetan conversion is guarded to the upstream Gregorian c Plum-rain dates describe traditional calendar rules, not observed or forecast weather. Calendar enablement, visible features and week start persist through `AppSettings` and the -optional fields in `SettingsDocument`. Older backups preserve those settings. Additional +optional fields in `SettingsDocument`. Week start initially uses `Calendar.current.firstWeekday`, +then persists independently of later system changes; all seven weekdays are supported by the +settings picker, backups, month grids and widgets. Menu-bar lunar modes remain available in +every app language and use localized lunar dates. Older backups preserve those settings. Additional calendars default off and appear only in selected-day details. There is no event access or network request at runtime. Use `--snapshot --calendar-only` for deterministic light/dark calendar screenshots without starting unrelated samplers or scans. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2b17b30..88d60d4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -54,8 +54,8 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布 构建固定使用 `arm64`,仅支持 Apple Silicon Mac。 有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则由构建脚本逐层补齐 ad-hoc 签名与 entitlement。临时签名没有团队身份,不能加载启用团队验证的 Sparkle 动态库,因此仅此本地/CI 路径关闭 Hardened Runtime;正式 Developer ID 构建仍强制启用,且临时签名构建不能在线安装或操作特权辅助工具。 桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。 -菜单栏日历提供“日期与星期”“自定义”显示方式;应用语言为简体中文或繁体中文(含自动检测为中文)时,额外提供“日期与农历”“农历”。切到非中文时,已保存的农历方式暂以“日期与星期”呈现,切回中文恢复原选择,不改写备份。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 -日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、星期、大陆节假日与调休、传统与公历节日、节气、黄历及假期概览;其他地区(含台湾、香港、澳门)默认只启用星期。干支、三伏/梅雨/数九、藏历与回历各地区均默认关闭。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”显示无法判断;黄历关闭后不再计算宜忌摘要。 +菜单栏日历在所有应用语言下均提供“日期与星期”“日期与农历”“农历”“自定义”显示方式;日期与农历按应用语言呈现,切换语言保留用户选择。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 +日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、星期、大陆节假日与调休、传统与公历节日、节气、黄历及假期概览;其他地区(含台湾、香港、澳门)默认只启用星期。干支、三伏/梅雨/数九、藏历与回历各地区均默认关闭。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”;黄历关闭后不再计算宜忌摘要。 月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。 月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。 用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group; diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift index a7874dc..6618231 100644 --- a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -54,7 +54,7 @@ extension Translations { 上班 Workday 不上班 Day off 暂无安排 No schedule data -暂无法判断 Cannot determine yet +仅支持中国大陆节假日 Only Mainland China holidays are supported 工作日 Weekday 周末 Weekend 节假日 Public holiday diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 00314e0..4b9a878 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط 上班 يوم عمل 不上班 يوم عطلة 暂无安排 لا توجد بيانات -暂无法判断 لا يمكن التحديد حاليًا +仅支持中国大陆节假日 يدعم العطلات الرسمية في البر الرئيسي للصين فقط 工作日 يوم عمل 周末 عطلة نهاية الأسبوع 节假日 عطلة رسمية diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index d43cd8c..d9730f6 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st 上班 Arbeitstag 不上班 Frei 暂无安排 Keine Daten -暂无法判断 Derzeit nicht bestimmbar +仅支持中国大陆节假日 Nur Feiertage in Festlandchina werden unterstützt 工作日 Werktag 周末 Wochenende 节假日 Feiertag diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 047d94a..1091495 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A 上班 Día laborable 不上班 Día libre 暂无安排 Sin datos -暂无法判断 Aún no se puede determinar +仅支持中国大陆节假日 Solo se admiten los festivos de China continental 工作日 Día laborable 周末 Fin de semana 节假日 Festivo diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index a121ff0..efcf840 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne 上班 Jour travaillé 不上班 Repos 暂无安排 Aucune donnée -暂无法判断 Impossible à déterminer pour le moment +仅支持中国大陆节假日 Seuls les jours fériés de Chine continentale sont pris en charge 工作日 Jour ouvré 周末 Week-end 节假日 Jour férié diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 397eddf..b78f718 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ 上班 勤務日 不上班 休み 暂无安排 予定データなし -暂无法判断 判定できません +仅支持中国大陆节假日 中国本土の祝日のみ対応 工作日 平日 周末 週末 节假日 祝日 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index a4b7629..8848b4a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택 上班 근무 不上班 쉬는 날 暂无安排 일정 데이터 없음 -暂无法判断 아직 판단할 수 없음 +仅支持中国大陆节假日 중국 본토 공휴일만 지원 工作日 평일 周末 주말 节假日 공휴일 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 456c036..cc8fd37 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1440,7 +1440,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源 上班 上班 不上班 不上班 暂无安排 暫無安排 -暂无法判断 暫時無法判斷 +仅支持中国大陆节假日 僅支援中國大陸節假日 工作日 工作日 周末 週末 节假日 節假日 diff --git a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift index 8043271..f405f57 100644 --- a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift +++ b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift @@ -178,7 +178,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { localUsageEnabled: Bool? = nil, publicIPEnabled: Bool = false, addresses: [Address] = [], language: String = "system", - calendarFirstWeekday: Int = 2, showsLunar: Bool = Locale.current.region?.identifier == "CN", + calendarFirstWeekday: Int = Calendar.current.firstWeekday, showsLunar: Bool = Locale.current.region?.identifier == "CN", calendarDays: [CalendarSummary]? = nil, monthSummaries: [MonthSummary]? = nil, showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil, showsAlmanac: Bool? = nil) { self.aiEnabled = aiEnabled diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift index 4fdb7bb..ad61105 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -147,7 +147,7 @@ enum CalendarEngine { static func month(year: Int, month: Int, firstWeekday: Int, timeZone: TimeZone = .autoupdatingCurrent) -> [CalendarDay] { let requested = CalendarMonth(year: year, month: month) - let weekStart = firstWeekday == 1 ? 1 : 2 + let weekStart = (1...7).contains(firstWeekday) ? firstWeekday : Calendar.current.firstWeekday // 固定自动更新时区的当前值,避免缓存里的时区也跟着系统变化,误判为可复用。 let zone = TimeZone(identifier: timeZone.identifier) ?? timeZone if let cached = monthCache, cached.month == requested, diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift index 9b0f1bc..564b15b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift @@ -10,17 +10,6 @@ public struct CalendarPreferences: Codable, Equatable, Sendable { case standard, dateLunar, lunar, custom public var id: String { rawValue } - static func available(for locale: Locale) -> [Self] { - locale.language.languageCode?.identifier == "zh" ? allCases : [.standard, .custom] - } - - /// 切换语言只改变呈现,不覆盖已保存的农历选择,切回中文后可恢复。 - func effective(for locale: Locale) -> Self { - if (self == .dateLunar || self == .lunar), locale.language.languageCode?.identifier != "zh" { - return .standard - } - return self - } var title: String { switch self { case .standard: tr("日期与星期") @@ -92,7 +81,7 @@ public struct CalendarPreferences: Codable, Equatable, Sendable { calendar.timeZone = timeZone formatter.calendar = calendar formatter.timeZone = timeZone - switch display.effective(for: locale) { + switch display { case .standard: formatter.setLocalizedDateFormatFromTemplate("MdEEE") case .custom: // 用户明确选择顺序,不能再用本地化模板把日月重新排回月日。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift index 519196d..6128942 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift @@ -36,15 +36,19 @@ struct CalendarOptionsPopover: View { var body: some View { @Bindable var settings = model.settings - PageScroll { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = L10n.locale + let weekdays = calendar.weekdaySymbols + return PageScroll { VStack(spacing: DS.Space.s4) { presentationOptions SettingsGroup(caption: tr("日历")) { GroupRow(showsDivider: false) { SettingRow(title: tr("每周开始于")) { Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) { - Text(tr("星期一")).tag(2) - Text(tr("星期日")).tag(1) + ForEach(1...7, id: \.self) { weekday in + Text(weekdays[weekday - 1]).tag(weekday) + } } .labelsHidden() .fixedSize() @@ -113,11 +117,8 @@ struct CalendarOptionsPopover: View { return SettingsGroup(caption: tr("菜单栏")) { GroupRow(showsDivider: false) { SettingRow(title: tr("显示方式")) { - Picker(tr("显示方式"), selection: Binding( - get: { settings.calendarPreferences.display.effective(for: L10n.locale) }, - set: { settings.calendarPreferences.display = $0 } - )) { - ForEach(CalendarPreferences.Display.available(for: L10n.locale)) { Text($0.title).tag($0) } + Picker(tr("显示方式"), selection: $settings.calendarPreferences.display) { + ForEach(CalendarPreferences.Display.allCases) { Text($0.title).tag($0) } }.labelsHidden().fixedSize() } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 1b90010..ce4d6c9 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -733,7 +733,7 @@ public final class AppSettings { public static let alertLoadOptions = [70, 80, 90] public static let cpuChartOptions = [60, 180, 300] - public init(defaults: UserDefaults = .standard, calendarLocale: Locale = .current) { + public init(defaults: UserDefaults = .standard, calendarLocale: Locale = .current, calendar: Calendar = .current) { self.defaults = defaults let isRestEnabled = defaults.bool(forKey: Keys.restEnabled) restEnabled = isRestEnabled @@ -758,7 +758,9 @@ public final class AppSettings { let savedCalendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures) calendarFeatures = savedCalendarFeatures .map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults(for: calendarLocale) - calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2 + let savedCalendarFirstWeekday = (defaults.object(forKey: Keys.calendarFirstWeekday) as? Int) + .flatMap { (1...7).contains($0) ? $0 : nil } + calendarFirstWeekday = savedCalendarFirstWeekday ?? calendar.firstWeekday let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:)) menuBarItems = Set(items ?? [.cpu, .memory, .network]) menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked @@ -837,6 +839,10 @@ public final class AppSettings { if savedCalendarFeatures == nil { defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) } + // 首次采用系统的周起始日并保存,之后系统设置变化不覆盖用户选择。 + if savedCalendarFirstWeekday == nil { + defaults.set(calendarFirstWeekday, forKey: Keys.calendarFirstWeekday) + } } /// 按固定顺序返回当前实际显示的菜单栏项目;可选模块关闭时保留展示偏好但不渲染。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index 0f8896a..65c7aef 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -144,7 +144,7 @@ extension AppSettings { assign(\.restSound, doc.restSound.flatMap(RestSound.init(rawValue:))) assign(\.restHUDStyle, doc.restHUDStyle.flatMap(RestHUDStyle.init(rawValue:))) assign(\.calendarFeatures, doc.calendarFeatures.map(CalendarFeature.restored(from:))) - assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: [1, 2])) + assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: Array(1...7))) assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) }) assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:))) assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:))) diff --git a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift index b6c3276..f12add7 100644 --- a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift +++ b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift @@ -31,6 +31,15 @@ import WidgetData #expect(disabled.visibleAddresses.isEmpty) } +@Test(arguments: Array(1...7)) +func snapshotPreservesEveryWeekStartAndReloadsCalendars(firstWeekday: Int) throws { + let previous = WidgetSnapshot(calendarFirstWeekday: firstWeekday % 7 + 1) + let snapshot = WidgetSnapshot(calendarFirstWeekday: firstWeekday) + let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot)) + #expect(restored.calendarFirstWeekday == firstWeekday) + #expect(restored.changedWidgetKinds(from: previous) == [WidgetKind.calendar, WidgetKind.tomorrowWork, WidgetKind.calendarMonth]) +} + @Test func calendarSummaryUsesLocalCivilDayAndOldCacheStillDecodes() throws { let legacy = Data(#"{"aiEnabled":false,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8) let oldSnapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift index 93ba8a7..8a2dbab 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -63,29 +63,32 @@ struct CalendarEnhancementTests { #expect(CalendarEngine.lunarDateTitle(at: newYear, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "正月初一") } - @Test(arguments: ["zh_Hans_CN", "zh_Hans_SG", "zh_Hant_TW", "zh_Hant_HK"]) - func chineseLanguagesOfferLunarDisplayRegardlessOfRegion(localeID: String) throws { + @Test(arguments: ["en_CN", "en_US", "ja_JP", "ko_KR", "de_DE", "es_ES", "fr_FR", "ar_SA"]) + func manualLunarDisplaySurvivesEveryLanguage(localeID: String) throws { let locale = Locale(identifier: localeID) - #expect(CalendarPreferences.Display.available(for: locale) == [.standard, .dateLunar, .lunar, .custom]) - #expect(CalendarPreferences.Display.lunar.effective(for: locale) == .lunar) - #expect(CalendarPreferences.Display.dateLunar.effective(for: locale) == .dateLunar) + let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z")) + let expected = try #require(CalendarEngine.lunarDateTitle(at: date, locale: locale, timeZone: zone)) + let standard = CalendarPreferences().title(at: date, locale: locale, timeZone: zone) + var preferences = CalendarPreferences() + preferences.display = .lunar + #expect(preferences.title(at: date, locale: locale, timeZone: zone) == expected) + #expect(preferences.title(at: date, locale: locale, timeZone: zone) != standard) + preferences.display = .dateLunar + #expect(preferences.title(at: date, locale: locale, timeZone: zone).contains(expected)) + let restored = try JSONDecoder().decode(CalendarPreferences.self, from: JSONEncoder().encode(preferences)) + #expect(restored.display == .dateLunar) + #expect(restored.title(at: date, locale: locale, timeZone: zone) == preferences.title(at: date, locale: locale, timeZone: zone)) } - @Test(arguments: ["en_CN", "en_US", "ja_JP", "ko_KR", "de_DE", "es_ES", "fr_FR", "ar_SA"]) - func nonChineseLanguagesTemporarilyUseStandardDisplay(localeID: String) throws { - let locale = Locale(identifier: localeID) - #expect(CalendarPreferences.Display.available(for: locale) == [.standard, .custom]) - let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z")) - let standard = CalendarPreferences().title(at: date, locale: locale, timeZone: zone) - for display in [CalendarPreferences.Display.lunar, .dateLunar] { - var preferences = CalendarPreferences() - preferences.display = display - #expect(preferences.title(at: date, locale: locale, timeZone: zone) == standard) - // 呈现回退不应被写入备份,切回繁体中文仍使用原选择。 - let restored = try JSONDecoder().decode(CalendarPreferences.self, from: JSONEncoder().encode(preferences)) - #expect(restored.display == display) - #expect(restored.display.effective(for: Locale(identifier: "zh_Hant_TW")) == display) - } + @Test(arguments: Array(1...7)) + func monthGridAndWidgetHonorEveryWeekStart(firstWeekday: Int) throws { + let days = CalendarEngine.month(year: 2026, month: 9, firstWeekday: firstWeekday, timeZone: zone) + let first = try #require(days.first) + #expect(days.count == 42 && first.weekday == firstWeekday) + let summary = CalendarEngine.widgetMonthSummary(year: 2026, month: 9, firstWeekday: firstWeekday, + features: [], timeZone: zone) + #expect(summary.firstWeekday == firstWeekday) + #expect(summary.days.first?.dateKey == first.id) } @Test(arguments: ["en_US", "en_GB", "zh_Hans_CN"]) @@ -164,6 +167,49 @@ struct CalendarEnhancementTests { #expect(!disabled.calendarPreferences.showHolidayOverview && !disabled.calendarPreferences.showAlmanac) } + @Test(arguments: Array(1...7)) + func weekStartUsesSystemDefaultOnceAndPreservesManualChoice(firstWeekday: Int) throws { + let name = "CalendarWeekStartTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + var calendar = Calendar(identifier: .gregorian) + calendar.firstWeekday = firstWeekday + let settings = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "en_US"), calendar: calendar) + #expect(settings.calendarFirstWeekday == firstWeekday) + #expect(defaults.integer(forKey: "calendarFirstWeekday") == firstWeekday) + + calendar.firstWeekday = firstWeekday % 7 + 1 + let restored = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "zh_CN"), calendar: calendar) + #expect(restored.calendarFirstWeekday == firstWeekday) + settings.calendarFirstWeekday = 3 + #expect(AppSettings(defaults: defaults, calendar: calendar).calendarFirstWeekday == 3) + } + + @Test(arguments: Array(1...7)) + func backupsRestoreEveryWeekStartAndRejectInvalidValues(firstWeekday: Int) throws { + let name = "CalendarWeekBackupTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + var calendar = Calendar(identifier: .gregorian) + calendar.firstWeekday = 7 + defaults.set(99, forKey: "calendarFirstWeekday") + let settings = AppSettings(defaults: defaults, calendar: calendar) + #expect(settings.calendarFirstWeekday == 7) + #expect(defaults.integer(forKey: "calendarFirstWeekday") == 7) + + settings.calendarFirstWeekday = firstWeekday + let document = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(settings.exportDocument())) + settings.calendarFirstWeekday = firstWeekday % 7 + 1 + settings.apply(document) + #expect(settings.calendarFirstWeekday == firstWeekday) + var invalid = SettingsDocument() + invalid.calendarFirstWeekday = 99 + settings.apply(invalid) + #expect(settings.calendarFirstWeekday == firstWeekday) + settings.apply(SettingsDocument()) + #expect(settings.calendarFirstWeekday == firstWeekday) + } + @Test func existingCalendarChoicesAndBackupsSurviveRegionalDefaults() throws { let name = "CalendarExistingRegionTests.\(UUID())" let defaults = try #require(UserDefaults(suiteName: name)) diff --git a/Widget/CalendarWidget.swift b/Widget/CalendarWidget.swift index 015daf9..657cffc 100644 --- a/Widget/CalendarWidget.swift +++ b/Widget/CalendarWidget.swift @@ -18,7 +18,8 @@ struct CalendarWidgetEntry: TimelineEntry { struct CalendarWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> CalendarWidgetEntry { - .init(date: .now, showsLunar: WidgetSnapshot().showsLunar, showsSeasonal: false, firstWeekday: 2, + let snapshot = WidgetSnapshot() + return .init(date: .now, showsLunar: snapshot.showsLunar, showsSeasonal: false, firstWeekday: snapshot.calendarFirstWeekday, today: nil, tomorrow: nil, month: nil) } @@ -175,7 +176,9 @@ struct CalendarWidget: Widget { private struct MonthCalendarWidgetView: View { let entry: CalendarWidgetEntry - private var firstWeekday: Int { entry.firstWeekday == 1 ? 1 : 2 } + private var firstWeekday: Int { + (1...7).contains(entry.firstWeekday) ? entry.firstWeekday : Calendar.current.firstWeekday + } private var calendar: Calendar { WidgetSnapshot.keyCalendar } private var monthKey: String { WidgetSnapshot.monthKey(for: entry.date) } @@ -320,7 +323,7 @@ private struct TomorrowWorkWidgetView: View { .minimumScaleFactor(0.75) } } else { - Text(tr("暂无法判断")) + Text(tr("仅支持中国大陆节假日")) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) }