fix(calendar): 老用户沿用旧版默认值并区分小组件节假日提示

- 已有安装缺少日历选项时沿用旧版默认与周一起始,不按地区重新初始化
- “明天上班吗”区分未启用节假日与安排尚未公布,恢复“暂无法判断”译文
- 截图固定中国大陆默认值与周起始日,移除默认参数与无调用方的 defaults
This commit is contained in:
ysicing authored and 缘生 committed 2026-09-30 23:50:16 +08:00
1 parent c0976ef649
commit dacc9cbbaa
20 files changed
+65 -23

No files matched your search

+3 -2
View File
@@ -189,8 +189,9 @@ Selected-day almanac is always enabled; legacy `showAlmanac` preferences are ign
Details still calculate on demand, while widgets reuse the bounded eight-day summary.
The snapshot keeps `showsAlmanac = true` to invalidate old disabled-almanac caches.
Calendar enablement, optional features and week start persist through `AppSettings` and the
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
optional fields in `SettingsDocument`. Fresh installs take region defaults and `Calendar.current.firstWeekday`; existing installs
(an existing `calendarEnabled` key) without saved calendar values keep the legacy defaults and
Monday. Both then persist 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 when lunar display is enabled. Disabling
lunar display hides its menu-bar presets and text-style controls, renders the saved lunar
+1 -1
View File
@@ -55,7 +55,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
有 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)默认启用农历、大陆节假日与调休、传统与公历节日、节气与时令及假期概览;其他地区(含台湾、香港、澳门)默认不启用这些地区相关的可选内容。各地区的月历和单日详情均固定显示星期,不提供独立开关;旧配置与备份中的星期开关被忽略,其他选择保留。藏历与回历各地区均默认关闭。“节气与时令”统一控制二十四节气、三伏、梅雨与数九;月历只显示节气当天、时令阶段起点及出梅,单日详情与单日小组件保留具体天数。旧节气或时令开关任一开启即恢复为开启,全部关闭才恢复为关闭;使用新的设置键使 Widget 旧摘要失效并重新生成。月历不再显示干支,也不提供对应开关;年月日干支只在日期详情中显示,旧偏好与备份中的干支选项被忽略,其他选项保留。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”;黄历在单日详情中固定显示,旧偏好与备份中的黄历开关被忽略;宜忌详情仍只为选中日期按需计算,小组件复用主应用预计算的八天摘要缓存。
日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、大陆节假日与调休、传统与公历节日、节气与时令及假期概览;其他地区(含台湾、香港、澳门)默认不启用这些地区相关的可选内容。各地区的月历和单日详情均固定显示星期,不提供独立开关;旧配置与备份中的星期开关被忽略,其他选择保留。藏历与回历各地区均默认关闭。“节气与时令”统一控制二十四节气、三伏、梅雨与数九;月历只显示节气当天、时令阶段起点及出梅,单日详情与单日小组件保留具体天数。旧节气或时令开关任一开启即恢复为开启,全部关闭才恢复为关闭;使用新的设置键使 Widget 旧摘要失效并重新生成。月历不再显示干支,也不提供对应开关;年月日干支只在日期详情中显示,旧偏好与备份中的干支选项被忽略,其他选项保留。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择;曾开关过日历功能的已有安装若未保存日历选项,沿用旧版默认(与中国大陆默认相同,每周从星期一开始),不按地区重新初始化。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”,已启用但次日所在年份安排尚未公布时提示“暂无法判断”;黄历在单日详情中固定显示,旧偏好与备份中的黄历开关被忽略;宜忌详情仍只为选中日期按需计算,小组件复用主应用预计算的八天摘要缓存。
月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。
月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
@@ -51,6 +51,7 @@ extension Translations {
不上班 Day off
暂无安排 No schedule data
仅支持中国大陆节假日 Only Mainland China holidays are supported
暂无法判断 Cannot determine yet
工作日 Weekday
周末 Weekend
节假日 Public holiday
@@ -1435,6 +1435,7 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط
不上班 يوم عطلة
暂无安排 لا توجد بيانات
仅支持中国大陆节假日 يدعم العطلات الرسمية في البر الرئيسي للصين فقط
暂无法判断 لا يمكن التحديد حاليًا
工作日 يوم عمل
周末 عطلة نهاية الأسبوع
节假日 عطلة رسمية
1 # Copyright (C) 2026 ysicing
1435 不上班
1436 暂无安排
1437 仅支持中国大陆节假日
1438 暂无法判断
1439 工作日
1440 周末
1441 节假日
@@ -1435,6 +1435,7 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st
不上班 Frei
暂无安排 Keine Daten
仅支持中国大陆节假日 Nur Feiertage in Festlandchina werden unterstützt
暂无法判断 Derzeit nicht bestimmbar
工作日 Werktag
周末 Wochenende
节假日 Feiertag
1 # Copyright (C) 2026 ysicing
1435 不上班
1436 暂无安排
1437 仅支持中国大陆节假日
1438 暂无法判断
1439 工作日
1440 周末
1441 节假日
@@ -1435,6 +1435,7 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A
不上班 Día libre
暂无安排 Sin datos
仅支持中国大陆节假日 Solo se admiten los festivos de China continental
暂无法判断 Aún no se puede determinar
工作日 Día laborable
周末 Fin de semana
节假日 Festivo
1 # Copyright (C) 2026 ysicing
1435 不上班
1436 暂无安排
1437 仅支持中国大陆节假日
1438 暂无法判断
1439 工作日
1440 周末
1441 节假日
@@ -1435,6 +1435,7 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne
不上班 Repos
暂无安排 Aucune donnée
仅支持中国大陆节假日 Seuls les jours fériés de Chine continentale sont pris en charge
暂无法判断 Impossible à déterminer pour le moment
工作日 Jour ouvré
周末 Week-end
节假日 Jour férié
1 # Copyright (C) 2026 ysicing
1435 不上班
1436 暂无安排
1437 仅支持中国大陆节假日
1438 暂无法判断
1439 工作日
1440 周末
1441 节假日
@@ -1435,6 +1435,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ
不上班 休み
暂无安排 予定データなし
仅支持中国大陆节假日 中国本土の祝日のみ対応
暂无法判断 判定できません
工作日 平日
周末 週末
节假日 祝日
1 # Copyright (C) 2026 ysicing
1435 不上班
1436 暂无安排
1437 仅支持中国大陆节假日
1438 暂无法判断
1439 工作日
1440 周末
1441 节假日
@@ -1435,6 +1435,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택
不上班 쉬는 날
暂无安排 일정 데이터 없음
仅支持中国大陆节假日 중국 본토 공휴일만 지원
暂无法判断 아직 판단할 수 없음
工作日 평일
周末 주말
节假日 공휴일
1 # Copyright (C) 2026 ysicing
1435 不上班
1436 暂无安排
1437 仅支持中国大陆节假日
1438 暂无法判断
1439 工作日
1440 周末
1441 节假日
@@ -1436,6 +1436,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源
不上班 不上班
暂无安排 暫無安排
仅支持中国大陆节假日 僅支援中國大陸節假日
暂无法判断 暫時無法判斷
工作日 工作日
周末 週末
节假日 節假日
1 # Copyright (c) 2026 GiantAccel, LLC
1436 不上班
1437 暂无安排
1438 仅支持中国大陆节假日
1439 暂无法判断
1440 工作日
1441 周末
1442 节假日
@@ -22,13 +22,15 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
case work, weekend, holiday, makeupWork, makeupDayOff, unknown
/// 有放假数据但未细分法定假日与调休的年份。
case dayOff
/// 用户未启用中国大陆节假日;与数据未覆盖的 unknown 分开提示。
case holidaysDisabled
/// 数据未覆盖的年份不能按普通工作日推断,避免给出错误的二元答案。
public var needsWork: Bool? {
switch self {
case .work, .makeupWork: true
case .weekend, .holiday, .makeupDayOff, .dayOff: false
case .unknown: nil
case .unknown, .holidaysDisabled: nil
}
}
}
@@ -48,7 +48,9 @@ extension CalendarEngine {
static func widgetSummary(for day: CalendarDay, features: Set<CalendarFeature> = Set(CalendarFeature.allCases)) -> WidgetSnapshot.CalendarSummary {
let schedule: WidgetSnapshot.CalendarSummary.Schedule
if !features.contains(.holidays) || !hasHolidayData(year: day.year) {
if !features.contains(.holidays) {
schedule = .holidaysDisabled
} else if !hasHolidayData(year: day.year) {
schedule = .unknown
} else if let holiday = day.holiday {
if holiday.isWork {
@@ -11,11 +11,12 @@ import WidgetData
public enum CalendarFeature: String, CaseIterable, Identifiable, Sendable {
case lunar, holidays, festivals, seasonalInfo, tibetan, hijri
public var id: String { rawValue }
public static var defaults: Set<Self> { defaults(for: .current) }
/// 中国大陆默认值,也是按地区区分之前的旧版默认;已有安装缺少保存值时沿用,避免升级后日历内容消失。
static let mainlandChinaDefaults: Set<Self> = [.lunar, .holidays, .festivals, .seasonalInfo]
/// 默认值依据使用地区,不依据应用语言或设备销售地;其他地区可自行启用。
public static func defaults(for locale: Locale) -> Set<Self> {
locale.region?.identifier == "CN" ? [.lunar, .holidays, .festivals, .seasonalInfo] : []
locale.region?.identifier == "CN" ? mainlandChinaDefaults : []
}
var title: String {
@@ -47,7 +47,7 @@ struct CalendarPopover: View {
private let sizing: CalendarPopoverSizing
init(referenceDate: Date? = nil, showsDayDetails: Bool = false,
sizing: CalendarPopoverSizing = .standard, firstWeekday: Int = 2) {
sizing: CalendarPopoverSizing = .standard, firstWeekday: Int) {
self.referenceDate = referenceDate
self.sizing = sizing
let today = CalendarEngine.today(at: referenceDate ?? Date())
@@ -128,7 +128,10 @@ enum SnapshotRenderer {
let name = "XStats.calendarSnapshot.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else { return }
defer { defaults.removePersistentDomain(forName: name) }
let settings = AppSettings(defaults: defaults)
// 固定地区与周起始日,截图不随运行机器的系统设置变化。
var weekCalendar = Calendar(identifier: .gregorian)
weekCalendar.firstWeekday = 2
let settings = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "zh_CN"), calendar: weekCalendar)
settings.calendarEnabled = true
settings.language = L10n.language
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [])
@@ -137,14 +140,14 @@ enum SnapshotRenderer {
for (name, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
guard let appearance = NSAppearance(named: name) else { continue }
NSApp.appearance = appearance
settings.calendarFeatures = CalendarFeature.defaults
write(CalendarPopover(referenceDate: september), model: model, appearance: appearance,
settings.calendarFeatures = CalendarFeature.mainlandChinaDefaults
write(CalendarPopover(referenceDate: september, firstWeekday: settings.calendarFirstWeekday), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-\(suffix).png"))
settings.calendarPreferences.largeLunarText = true
settings.calendarPreferences.strongerLunarText = true
settings.calendarPreferences.showEvents = true
settings.calendarPreferences.showReminders = true
write(CalendarPopover(referenceDate: september), model: model, appearance: appearance,
write(CalendarPopover(referenceDate: september, firstWeekday: settings.calendarFirstWeekday), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-agenda-\(suffix).png"))
write(CalendarOptionsPopover(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-options-\(suffix).png"))
@@ -155,12 +158,12 @@ enum SnapshotRenderer {
settings.calendarPreferences.display = .dateLunar
write(CalendarOptionsPopover(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-lunar-\(suffix).png"))
// 重置呈现偏好;黄历在日期详情中固定显示,其他选项仍按本机地区初始化。
// 重置呈现偏好;黄历在日期详情中固定显示,其他选项沿用固定的中国大陆默认值。
settings.calendarPreferences = CalendarPreferences(locale: Locale(identifier: "zh_CN"))
write(CalendarPopover(referenceDate: september, showsDayDetails: true), model: model, appearance: appearance,
write(CalendarPopover(referenceDate: september, showsDayDetails: true, firstWeekday: settings.calendarFirstWeekday), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-almanac-\(suffix).png"))
settings.calendarFeatures = Set(CalendarFeature.allCases)
write(CalendarPopover(referenceDate: summer, showsDayDetails: true), model: model, appearance: appearance,
write(CalendarPopover(referenceDate: summer, showsDayDetails: true, firstWeekday: settings.calendarFirstWeekday), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-details-\(suffix).png"))
}
}
@@ -749,18 +749,22 @@ public final class AppSettings {
?? (defaults.bool(forKey: Keys.restCycleEnabled) ? .cycle : .single)
restSound = defaults.string(forKey: Keys.restSound).flatMap(RestSound.init(rawValue:)) ?? .off
restHUDStyle = defaults.string(forKey: Keys.restHUDStyle).flatMap(RestHUDStyle.init(rawValue:)) ?? .countdown
// 旧版只在用户开关日历后写入该键;已有安装缺少保存值时沿用旧版默认,不按地区重新初始化。
let isExistingCalendarInstall = defaults.object(forKey: Keys.calendarEnabled) != nil
calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled)
let savedCalendarPreferences = defaults.data(forKey: Keys.calendarPreferences)
calendarPreferences = savedCalendarPreferences
.flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) } ?? CalendarPreferences(locale: calendarLocale)
.flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) }
?? CalendarPreferences(locale: isExistingCalendarInstall ? Locale(identifier: "zh_CN") : calendarLocale)
calendarEventSourceIDs = defaults.stringArray(forKey: "calendarEventSourceIDs").map { Set($0) }
calendarReminderSourceIDs = defaults.stringArray(forKey: "calendarReminderSourceIDs").map { Set($0) }
let savedCalendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures)
calendarFeatures = savedCalendarFeatures
.map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults(for: calendarLocale)
.map(CalendarFeature.restored(from:))
?? (isExistingCalendarInstall ? CalendarFeature.mainlandChinaDefaults : CalendarFeature.defaults(for: calendarLocale))
let savedCalendarFirstWeekday = (defaults.object(forKey: Keys.calendarFirstWeekday) as? Int)
.flatMap { (1...7).contains($0) ? $0 : nil }
calendarFirstWeekday = savedCalendarFirstWeekday ?? calendar.firstWeekday
calendarFirstWeekday = savedCalendarFirstWeekday ?? (isExistingCalendarInstall ? 2 : 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
@@ -12,7 +12,7 @@ struct CalendarAlmanacTests {
let day = try #require(CalendarEngine.day(year: date.0, month: date.1, day: date.2))
let summary = CalendarEngine.widgetSummary(for: day, features: [])
#expect(summary.festivals.isEmpty && summary.solarTerm == nil)
#expect(summary.holidayName == nil && summary.schedule.needsWork == nil)
#expect(summary.holidayName == nil && summary.schedule == .holidaysDisabled)
#expect(summary.seasonalDescriptions == nil)
#expect(summary.twelveStar != nil)
}
@@ -26,7 +26,7 @@ struct CalendarEnhancementTests {
window.isReleasedWhenClosed = false
defer { window.close() }
for details in [false, true] {
let hosting = NSHostingView(rootView: CalendarPopover(referenceDate: date, showsDayDetails: details)
let hosting = NSHostingView(rootView: CalendarPopover(referenceDate: date, showsDayDetails: details, firstWeekday: model.settings.calendarFirstWeekday)
.environment(model)
.environment(\.reportPopoverHeight, reporter))
window.contentView = hosting
@@ -193,6 +193,27 @@ struct CalendarEnhancementTests {
#expect(!disabled.calendarPreferences.showHolidayOverview)
}
@Test(arguments: [true, false])
func existingInstallKeepsLegacyCalendarDefaultsInEveryRegion(calendarEnabled: Bool) throws {
let name = "CalendarUpgradeTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
// 旧版只写入过日历开关,未保存任何日历选项。
defaults.set(calendarEnabled, forKey: "calendarEnabled")
var calendar = Calendar(identifier: .gregorian)
calendar.firstWeekday = 1
let settings = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "en_US"), calendar: calendar)
#expect(settings.calendarEnabled == calendarEnabled)
#expect(settings.calendarFeatures == [.lunar, .holidays, .festivals, .seasonalInfo])
#expect(settings.calendarPreferences.showHolidayOverview)
#expect(settings.calendarFirstWeekday == 2)
// 沿用的默认值在首次启动时保存,之后地区或系统周起始日变化不再影响。
let reloaded = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "ja_JP"), calendar: calendar)
#expect(reloaded.calendarFeatures == settings.calendarFeatures)
#expect(reloaded.calendarPreferences == settings.calendarPreferences)
#expect(reloaded.calendarFirstWeekday == 2)
}
@Test(arguments: Array(1...7))
func weekStartUsesSystemDefaultOnceAndPreservesManualChoice(firstWeekday: Int) throws {
let name = "CalendarWeekStartTests.\(UUID())"
@@ -219,7 +219,7 @@ struct CalendarTests {
defer { defaults.removePersistentDomain(forName: name) }
let settings = AppSettings(defaults: defaults)
#expect(settings.calendarEnabled == false)
#expect(settings.calendarFeatures == CalendarFeature.defaults)
#expect(settings.calendarFeatures == CalendarFeature.defaults(for: .current))
settings.calendarEnabled = true
settings.calendarFeatures = [.hijri, .lunar]
settings.calendarFirstWeekday = 1
+2 -2
View File
@@ -296,7 +296,7 @@ private struct TomorrowWorkWidgetView: View {
case .makeupWork: tr("调休补班")
case .makeupDayOff: tr("调休放假")
case .dayOff: tr("放假")
case .unknown: nil
case .unknown, .holidaysDisabled: nil
}
}
@@ -323,7 +323,7 @@ private struct TomorrowWorkWidgetView: View {
.minimumScaleFactor(0.75)
}
} else {
Text(tr("仅支持中国大陆节假日"))
Text(tr(entry.tomorrow?.schedule == .holidaysDisabled ? "仅支持中国大陆节假日" : "暂无法判断"))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}