From aa8228fac8b52eb891ed4f6df6bba234c697359e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=98=E7=94=9F?= <8605565+ysicing@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:00:05 +0800 Subject: [PATCH] Codex/calendar (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(calendar): 新增菜单栏日历与黄历详情 --- ARCHITECTURE.md | 25 ++ LICENSES/Tyme4Swift-MIT.txt | 21 ++ Packages/XStatsKit/Package.swift | 6 +- .../Localization/CalendarTranslations.swift | 96 +++++++ .../Sources/Localization/Resources/ar.tsv | 89 +++++++ .../Sources/Localization/Resources/de.tsv | 89 +++++++ .../Sources/Localization/Resources/es.tsv | 89 +++++++ .../Sources/Localization/Resources/fr.tsv | 89 +++++++ .../Sources/Localization/Resources/ja.tsv | 89 +++++++ .../Sources/Localization/Resources/ko.tsv | 89 +++++++ .../Localization/Resources/zh-Hant.tsv | 89 +++++++ .../Sources/Localization/Translations.swift | 2 +- .../XStatsUI/AppKit/AppController.swift | 17 ++ .../XStatsUI/Calendar/CalendarAlmanac.swift | 65 +++++ .../Calendar/CalendarAlmanacView.swift | 212 +++++++++++++++ .../XStatsUI/Calendar/CalendarEngine.swift | 164 ++++++++++++ .../Calendar/CalendarMenuBarController.swift | 136 ++++++++++ .../XStatsUI/Calendar/CalendarPopover.swift | 250 ++++++++++++++++++ .../XStatsUI/Calendar/CalendarSettings.swift | 48 ++++ .../XStatsUI/Settings/SettingsPages.swift | 1 + .../XStatsUI/Snapshot/SnapshotRenderer.swift | 29 ++ .../Sources/XStatsUI/State/AppSettings.swift | 18 ++ .../XStatsUI/State/SettingsDocument.swift | 9 + .../XStatsUITests/CalendarAlmanacTests.swift | 52 ++++ .../Tests/XStatsUITests/CalendarTests.swift | 129 +++++++++ README.en.md | 15 ++ README.ja.md | 13 + README.ko.md | 13 + README.md | 12 + ThirdPartyNotices.md | 7 + 30 files changed, 1961 insertions(+), 2 deletions(-) create mode 100644 LICENSES/Tyme4Swift-MIT.txt create mode 100644 Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79082be..240c7bc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,6 +167,31 @@ an empty compact toolbar, so the traffic lights sit on the same ground colour as line up with the 40 pt page header; the app switches to a regular activation policy while a window is open and back to accessory when all are closed. +## Calendar + +`CalendarMenuBarController` owns a separate, opt-in status item, independent of the metrics +layout and `MetricsHub`. It reuses `StatusPanel`, updates the displayed date every minute and +on day/time-zone changes or wake, and stops its timer when disabled. `CalendarPopover` uses a +six-week Gregorian grid (1900–2100) and month/year navigation. Clicking a day opens an almanac +page in the same panel; returning preserves the selected date and month. `CalendarAlmanac` +loads Tyme's daily advice, deities and hour fortunes only for the selected day and caches the +value model by civil-date ID. The twelve-hour grid uses early Zi through Hai, excluding the +next day's late Zi entry at 23:00; each cell exposes its exact time range in a tooltip. Browsing +history preserves the selection across midnight; a selection on today follows the new day. + +`CalendarEngine` is the sole adapter for the pinned MIT-licensed Tyme4Swift 1.5.0 dependency. +All Tyme calls run on MainActor because upstream exposes mutable static tables; only value +models leave the adapter. Civil dates use the system time zone and calendar day arithmetic, +not 24-hour intervals. Tibetan conversion is guarded to the upstream Gregorian coverage +1951-01-08 through 2051-02-11. Holiday data currently ends in 2026; unknown years show a notice. +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 +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. + ## AI usage statistics The active Codex provider reads local JSONL rollouts from CODEX_HOME (default ~/.codex), diff --git a/LICENSES/Tyme4Swift-MIT.txt b/LICENSES/Tyme4Swift-MIT.txt new file mode 100644 index 0000000..e6f8caa --- /dev/null +++ b/LICENSES/Tyme4Swift-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 6tail + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Packages/XStatsKit/Package.swift b/Packages/XStatsKit/Package.swift index 94b4cb0..6ead3a1 100644 --- a/Packages/XStatsKit/Package.swift +++ b/Packages/XStatsKit/Package.swift @@ -16,6 +16,9 @@ let package = Package( .library(name: "WebDAVSync", targets: ["WebDAVSync"]), .library(name: "XStatsUI", targets: ["XStatsUI"]), ], + dependencies: [ + .package(url: "https://github.com/6tail/tyme4swift.git", exact: "1.5.0"), + ], targets: [ .target(name: "Localization", resources: [.process("Resources")]), .target(name: "AIUsage"), @@ -25,7 +28,8 @@ let package = Package( .target(name: "Cleaner", dependencies: ["Localization"]), .target(name: "Updates", dependencies: ["Localization"]), .target(name: "WebDAVSync", dependencies: ["Localization"]), - .target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync"], + .target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync", + .product(name: "Tyme4Swift", package: "tyme4swift")], resources: [.copy("Resources/Flags"), .copy("Resources/Logos")]), .testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]), .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift new file mode 100644 index 0000000..3844f71 --- /dev/null +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -0,0 +1,96 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +extension Translations { + static let calendarEnglish = #""" +日历 Calendar +菜单栏日历 Menu Bar Calendar +日历设置… Calendar Settings… +独立显示日期,点击打开月历;不受指标合并布局影响 Show a separate date item; click to open the calendar, even when metrics are combined +每周开始于 First day of week +星期一 Monday +星期日 Sunday +农历 Lunar calendar +星期 Weekdays +法定节假日与调休 Holidays and makeup workdays +传统与公历节日 Festivals +二十四节气 Solar terms +干支 Sexagenary cycle +三伏天 Dog days +梅雨天 Plum-rain days +藏历 Tibetan calendar +回历 Hijri calendar +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Gregorian dates stay visible. Tibetan and Hijri dates appear in day details. Plum-rain days follow traditional calendar rules, not weather forecasts. +该日期暂无藏历数据 Tibetan calendar data is unavailable for this date +该年份暂无中国法定假日与调休数据 Chinese holiday and makeup workday data is unavailable for this year +按传统历法推算,并非天气预报 Traditional calendar calculation, not a weather forecast +年份 Year +月份 Month +上个月 Previous month +下个月 Next month +班 W +休 R +调休上班 Makeup workday +放假 Holiday +春节 Spring Festival +元宵节 Lantern Festival +龙头节 Dragon Head-Raising Festival +上巳节 Shangsi Festival +清明节 Qingming Festival +端午节 Dragon Boat Festival +七夕节 Qixi Festival +中元节 Ghost Festival +中秋节 Mid-Autumn Festival +重阳节 Double Ninth Festival +冬至节 Winter Solstice Festival +腊八节 Laba Festival +除夕 Lunar New Year's Eve +元旦 New Year's Day +劳动节 Labour Day +国庆节 National Day +国庆中秋 National Day / Mid-Autumn +抗战胜利日 Victory Day +立春 Start of Spring +雨水 Rain Water +惊蛰 Awakening of Insects +春分 Spring Equinox +清明 Pure Brightness +谷雨 Grain Rain +立夏 Start of Summer +小满 Grain Buds +芒种 Grain in Ear +夏至 Summer Solstice +小暑 Minor Heat +大暑 Major Heat +立秋 Start of Autumn +处暑 End of Heat +白露 White Dew +秋分 Autumn Equinox +寒露 Cold Dew +霜降 Frost's Descent +立冬 Start of Winter +小雪 Minor Snow +大雪 Major Snow +冬至 Winter Solstice +小寒 Minor Cold +大寒 Major Cold +返回月历 Back to calendar +控制月历格中的日干支;黄历详情始终显示年月日干支 Show the day cycle in month cells; almanac details always include year, month and day cycles +宜 Favorable +忌 Avoid +纳音 Na Yin +冲煞 Clash / Sha +冲{} 煞{} Clash: {} · Sha: {} +值神 Day deity +建除十二神 Twelve officers +吉神宜趋 Auspicious deities +今日胎神 Fetal deity +凶神宜忌 Inauspicious deities +彭祖百忌 Peng Zu taboos +二十八星宿 Lunar mansions +时辰吉凶 Hour fortunes +子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi shows 00:00–00:59; late Zi from 23:00 uses the next day's cycle +吉 Lucky +凶 Unlucky +"""# +} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 820e050..8a0e8b8 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1247,3 +1247,92 @@ Token 活动 نشاط الرموز 每周 أسبوعي {} 当周:{} Tokens أسبوع {}: {} Tokens 截至 {} 当周累计:{} Tokens الإجمالي حتى أسبوع {}: {} Tokens +日历 التقويم +菜单栏日历 تقويم شريط القوائم +日历设置… إعدادات التقويم… +独立显示日期,点击打开月历;不受指标合并布局影响 عرض التاريخ بشكل مستقل؛ انقر لفتح التقويم حتى عند دمج المؤشرات +每周开始于 أول أيام الأسبوع +星期一 الاثنين +星期日 الأحد +农历 التقويم القمري الصيني +星期 أيام الأسبوع +法定节假日与调休 العطلات وأيام العمل التعويضية +传统与公历节日 المناسبات التقليدية والميلادية +二十四节气 الفصول الشمسية الأربعة والعشرون +干支 الدورة الستينية +三伏天 أيام القيظ +梅雨天 أيام أمطار البرقوق التقليدية +藏历 التقويم التبتي +回历 التقويم الهجري +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 تظل التواريخ الميلادية ظاهرة. تظهر التواريخ التبتية والهجرية في تفاصيل اليوم. تُحسب أمطار البرقوق وفق القواعد التقليدية وليست توقعات جوية. +该日期暂无藏历数据 لا تتوفر بيانات التقويم التبتي لهذا التاريخ +该年份暂无中国法定假日与调休数据 لا تتوفر بيانات العطلات الصينية وأيام العمل التعويضية لهذه السنة +按传统历法推算,并非天气预报 حساب وفق التقويم التقليدي، وليس توقعًا جويًا +年份 السنة +月份 الشهر +上个月 الشهر السابق +下个月 الشهر التالي +班 ع +休 ر +调休上班 يوم عمل تعويضي +放假 عطلة +春节 عيد الربيع +元宵节 عيد الفوانيس +龙头节 مهرجان رأس التنين +上巳节 مهرجان شانغسي +清明节 عيد تشينغمينغ +端午节 عيد قوارب التنين +七夕节 عيد تشيشي +中元节 عيد الأشباح +中秋节 عيد منتصف الخريف +重阳节 عيد التسعة المزدوجة +冬至节 عيد الانقلاب الشتوي +腊八节 عيد لابا +除夕 ليلة رأس السنة القمرية +元旦 رأس السنة +劳动节 عيد العمال +国庆节 العيد الوطني +国庆中秋 العيد الوطني / منتصف الخريف +抗战胜利日 يوم النصر +立春 بداية الربيع +雨水 ماء المطر +惊蛰 استيقاظ الحشرات +春分 الاعتدال الربيعي +清明 الصفاء النقي +谷雨 مطر الحبوب +立夏 بداية الصيف +小满 الامتلاء الصغير +芒种 الحبوب في السنابل +夏至 الانقلاب الصيفي +小暑 الحر الصغير +大暑 الحر الكبير +立秋 بداية الخريف +处暑 نهاية الحر +白露 الندى الأبيض +秋分 الاعتدال الخريفي +寒露 الندى البارد +霜降 نزول الصقيع +立冬 بداية الشتاء +小雪 الثلج الصغير +大雪 الثلج الكبير +冬至 الانقلاب الشتوي +小寒 البرد الصغير +大寒 البرد الكبير +返回月历 العودة إلى التقويم +控制月历格中的日干支;黄历详情始终显示年月日干支 عرض دورة اليوم في خلايا الشهر؛ تتضمن تفاصيل التقويم التقليدي دائمًا دورات السنة والشهر واليوم +宜 مستحب +忌 يُتجنّب +纳音 نا يين +冲煞 التعارض / شا +冲{} 煞{} التعارض: {} · شا: {} +值神 حارس اليوم +建除十二神 الحراس الاثنا عشر +吉神宜趋 حراس الحظ الحسن +今日胎神 حارس الجنين +凶神宜忌 حراس النحس +彭祖百忌 محظورات بنغ تسو +二十八星宿 منازل القمر +时辰吉凶 حظ الساعات +子时显示当日早子时;23 点起的晚子时按次日干支计算 تُعرض فترة زي من 00:00 إلى 00:59؛ ومن 23:00 تُستخدم دورة اليوم التالي +吉 حسن +凶 سيئ diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index b2a1c22..df72794 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1247,3 +1247,92 @@ Token 活动 Token-Aktivität 每周 Wöchentlich {} 当周:{} Tokens Woche vom {}: {} Tokens 截至 {} 当周累计:{} Tokens Bis zur Woche vom {}: {} Tokens kumuliert +日历 Kalender +菜单栏日历 Menüleistenkalender +日历设置… Kalendereinstellungen… +独立显示日期,点击打开月历;不受指标合并布局影响 Separates Datum; ein Klick öffnet den Kalender, auch bei kombinierten Messwerten +每周开始于 Wochenbeginn +星期一 Montag +星期日 Sonntag +农历 Chinesischer Mondkalender +星期 Wochentage +法定节假日与调休 Feiertage und Ausgleichsarbeitstage +传统与公历节日 Traditionelle und gregorianische Feste +二十四节气 24 Sonnenperioden +干支 Sechzigjahreszyklus +三伏天 Hundstage +梅雨天 Traditionelle Pflaumenregenzeit +藏历 Tibetischer Kalender +回历 Hidschri-Kalender +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Gregorianische Daten bleiben sichtbar. Tibetische und Hidschri-Daten stehen in den Tagesdetails. Die Pflaumenregenzeit folgt traditionellen Kalenderregeln, nicht Wettervorhersagen. +该日期暂无藏历数据 Für dieses Datum sind keine tibetischen Kalenderdaten verfügbar +该年份暂无中国法定假日与调休数据 Für dieses Jahr fehlen chinesische Feiertags- und Ausgleichsarbeitstagsdaten +按传统历法推算,并非天气预报 Traditionelle Kalenderberechnung, keine Wettervorhersage +年份 Jahr +月份 Monat +上个月 Vorheriger Monat +下个月 Nächster Monat +班 A +休 F +调休上班 Ausgleichsarbeitstag +放假 Feiertag +春节 Frühlingsfest +元宵节 Laternenfest +龙头节 Longtaitou +上巳节 Shangsi-Fest +清明节 Qingming-Fest +端午节 Drachenbootfest +七夕节 Qixi-Fest +中元节 Geisterfest +中秋节 Mittherbstfest +重阳节 Doppelneunfest +冬至节 Wintersonnenwendfest +腊八节 Laba-Fest +除夕 Mondneujahrsabend +元旦 Neujahr +劳动节 Tag der Arbeit +国庆节 Nationalfeiertag +国庆中秋 Nationalfeiertag / Mittherbstfest +抗战胜利日 Tag des Sieges +立春 Frühlingsbeginn +雨水 Regenwasser +惊蛰 Erwachen der Insekten +春分 Frühlings-Tagundnachtgleiche +清明 Helle Klarheit +谷雨 Getreideregen +立夏 Sommerbeginn +小满 Kleine Fülle +芒种 Ährensaat +夏至 Sommersonnenwende +小暑 Kleine Hitze +大暑 Große Hitze +立秋 Herbstbeginn +处暑 Ende der Hitze +白露 Weißer Tau +秋分 Herbst-Tagundnachtgleiche +寒露 Kalter Tau +霜降 Reifbeginn +立冬 Winterbeginn +小雪 Kleiner Schnee +大雪 Großer Schnee +冬至 Wintersonnenwende +小寒 Kleine Kälte +大寒 Große Kälte +返回月历 Zurück zum Kalender +控制月历格中的日干支;黄历详情始终显示年月日干支 Tageszyklus in Monatszellen anzeigen; Almanachdetails enthalten immer Jahres-, Monats- und Tageszyklen +宜 Günstig +忌 Meiden +纳音 Na Yin +冲煞 Konflikt / Sha +冲{} 煞{} Konflikt: {} · Sha: {} +值神 Tagesgottheit +建除十二神 Zwölf Tagesämter +吉神宜趋 Günstige Gottheiten +今日胎神 Fötusgottheit +凶神宜忌 Ungünstige Gottheiten +彭祖百忌 Peng-Zu-Tabus +二十八星宿 Mondhäuser +时辰吉凶 Stundenglück +子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi zeigt 00:00–00:59; ab 23:00 gilt der Zyklus des Folgetags +吉 Glück +凶 Unglück diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 28009f3..a651851 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1247,3 +1247,92 @@ Token 活动 Actividad de tokens 每周 Semanal {} 当周:{} Tokens Semana del {}: {} Tokens 截至 {} 当周累计:{} Tokens Acumulado hasta la semana del {}: {} Tokens +日历 Calendario +菜单栏日历 Calendario de la barra de menús +日历设置… Ajustes del calendario… +独立显示日期,点击打开月历;不受指标合并布局影响 Muestra la fecha por separado; haz clic para abrir el calendario, incluso con métricas combinadas +每周开始于 Primer día de la semana +星期一 Lunes +星期日 Domingo +农历 Calendario lunar chino +星期 Días de la semana +法定节假日与调休 Festivos y días laborales compensatorios +传统与公历节日 Fiestas tradicionales y gregorianas +二十四节气 24 términos solares +干支 Ciclo sexagenario +三伏天 Días caniculares +梅雨天 Lluvias de ciruela tradicionales +藏历 Calendario tibetano +回历 Calendario hiyri +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Las fechas gregorianas siempre se muestran. Los calendarios tibetano e hiyri aparecen en los detalles del día. Las lluvias de ciruela se calculan según reglas tradicionales, no son pronósticos. +该日期暂无藏历数据 No hay datos del calendario tibetano para esta fecha +该年份暂无中国法定假日与调休数据 No hay datos de festivos chinos ni días laborales compensatorios para este año +按传统历法推算,并非天气预报 Cálculo del calendario tradicional, no un pronóstico +年份 Año +月份 Mes +上个月 Mes anterior +下个月 Mes siguiente +班 T +休 D +调休上班 Día laboral compensatorio +放假 Festivo +春节 Fiesta de la Primavera +元宵节 Fiesta de los Faroles +龙头节 Longtaitou +上巳节 Fiesta de Shangsi +清明节 Fiesta de Qingming +端午节 Fiesta del Bote del Dragón +七夕节 Fiesta de Qixi +中元节 Fiesta de los Fantasmas +中秋节 Fiesta del Medio Otoño +重阳节 Fiesta del Doble Nueve +冬至节 Fiesta del Solsticio de Invierno +腊八节 Fiesta de Laba +除夕 Víspera del Año Nuevo Lunar +元旦 Año Nuevo +劳动节 Día del Trabajo +国庆节 Fiesta Nacional +国庆中秋 Fiesta Nacional / Medio Otoño +抗战胜利日 Día de la Victoria +立春 Inicio de la primavera +雨水 Agua de lluvia +惊蛰 Despertar de los insectos +春分 Equinoccio de primavera +清明 Claridad pura +谷雨 Lluvia para el grano +立夏 Inicio del verano +小满 Pequeña abundancia +芒种 Grano en espiga +夏至 Solsticio de verano +小暑 Calor menor +大暑 Calor mayor +立秋 Inicio del otoño +处暑 Fin del calor +白露 Rocío blanco +秋分 Equinoccio de otoño +寒露 Rocío frío +霜降 Descenso de la escarcha +立冬 Inicio del invierno +小雪 Nieve menor +大雪 Nieve mayor +冬至 Solsticio de invierno +小寒 Frío menor +大寒 Frío mayor +返回月历 Volver al calendario +控制月历格中的日干支;黄历详情始终显示年月日干支 Muestra el ciclo diario en las celdas; el almanaque siempre incluye los ciclos del año, mes y día +宜 Favorable +忌 Evitar +纳音 Na Yin +冲煞 Conflicto / Sha +冲{} 煞{} Conflicto: {} · Sha: {} +值神 Deidad del día +建除十二神 Doce oficiales +吉神宜趋 Deidades favorables +今日胎神 Deidad fetal +凶神宜忌 Deidades desfavorables +彭祖百忌 Tabúes de Peng Zu +二十八星宿 Mansiones lunares +时辰吉凶 Fortuna horaria +子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi muestra 00:00–00:59; desde las 23:00 usa el ciclo del día siguiente +吉 Bueno +凶 Malo diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 107ad5c..214807c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1247,3 +1247,92 @@ Token 活动 Activité des tokens 每周 Par semaine {} 当周:{} Tokens Semaine du {} : {} Tokens 截至 {} 当周累计:{} Tokens Cumul jusqu’à la semaine du {} : {} Tokens +日历 Calendrier +菜单栏日历 Calendrier de la barre des menus +日历设置… Réglages du calendrier… +独立显示日期,点击打开月历;不受指标合并布局影响 Affiche la date séparément ; cliquez pour ouvrir le calendrier, même avec les indicateurs regroupés +每周开始于 Premier jour de la semaine +星期一 Lundi +星期日 Dimanche +农历 Calendrier lunaire chinois +星期 Jours de la semaine +法定节假日与调休 Jours fériés et jours de travail compensatoires +传统与公历节日 Fêtes traditionnelles et grégoriennes +二十四节气 24 périodes solaires +干支 Cycle sexagésimal +三伏天 Jours caniculaires +梅雨天 Pluies des prunes traditionnelles +藏历 Calendrier tibétain +回历 Calendrier hégirien +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Les dates grégoriennes restent visibles. Les dates tibétaines et hégiriennes figurent dans les détails du jour. Les pluies des prunes suivent les règles traditionnelles, sans prévision météo. +该日期暂无藏历数据 Aucune donnée du calendrier tibétain pour cette date +该年份暂无中国法定假日与调休数据 Aucune donnée sur les jours fériés chinois et les jours de travail compensatoires pour cette année +按传统历法推算,并非天气预报 Calcul du calendrier traditionnel, pas une prévision météo +年份 Année +月份 Mois +上个月 Mois précédent +下个月 Mois suivant +班 T +休 R +调休上班 Jour de travail compensatoire +放假 Jour férié +春节 Fête du Printemps +元宵节 Fête des Lanternes +龙头节 Longtaitou +上巳节 Fête de Shangsi +清明节 Fête de Qingming +端午节 Fête des Bateaux-Dragons +七夕节 Fête de Qixi +中元节 Fête des Fantômes +中秋节 Fête de la Mi-Automne +重阳节 Fête du Double Neuf +冬至节 Fête du Solstice d’Hiver +腊八节 Fête de Laba +除夕 Veille du Nouvel An lunaire +元旦 Jour de l’An +劳动节 Fête du Travail +国庆节 Fête nationale +国庆中秋 Fête nationale / Mi-Automne +抗战胜利日 Jour de la Victoire +立春 Début du printemps +雨水 Eau de pluie +惊蛰 Réveil des insectes +春分 Équinoxe de printemps +清明 Clarté pure +谷雨 Pluie des céréales +立夏 Début de l’été +小满 Petite plénitude +芒种 Grain en épi +夏至 Solstice d’été +小暑 Petite chaleur +大暑 Grande chaleur +立秋 Début de l’automne +处暑 Fin de la chaleur +白露 Rosée blanche +秋分 Équinoxe d’automne +寒露 Rosée froide +霜降 Arrivée du givre +立冬 Début de l’hiver +小雪 Petite neige +大雪 Grande neige +冬至 Solstice d’hiver +小寒 Petit froid +大寒 Grand froid +返回月历 Retour au calendrier +控制月历格中的日干支;黄历详情始终显示年月日干支 Affiche le cycle du jour dans les cases ; l’almanach inclut toujours les cycles de l’année, du mois et du jour +宜 Favorable +忌 À éviter +纳音 Na Yin +冲煞 Opposition / Sha +冲{} 煞{} Opposition : {} · Sha : {} +值神 Divinité du jour +建除十二神 Douze officiers +吉神宜趋 Divinités favorables +今日胎神 Divinité fœtale +凶神宜忌 Divinités défavorables +彭祖百忌 Tabous de Peng Zu +二十八星宿 Demeures lunaires +时辰吉凶 Fortune des heures +子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi affiche 00 h–00 h 59 ; dès 23 h, le cycle du lendemain s’applique +吉 Favorable +凶 Défavorable diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index d869ac6..dec294a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1247,3 +1247,92 @@ Token 活动 トークン活動 每周 週別 {} 当周:{} Tokens {} の週:{} Tokens 截至 {} 当周累计:{} Tokens {} の週までの累計:{} Tokens +日历 カレンダー +菜单栏日历 メニューバーカレンダー +日历设置… カレンダー設定… +独立显示日期,点击打开月历;不受指标合并布局影响 日付を独立表示し、クリックで月表示を開きます。指標の結合設定には影響されません +每周开始于 週の開始曜日 +星期一 月曜日 +星期日 日曜日 +农历 旧暦 +星期 曜日 +法定节假日与调休 祝日と振替出勤日 +传统与公历节日 伝統と西暦の祝祭日 +二十四节气 二十四節気 +干支 干支 +三伏天 三伏 +梅雨天 入梅・出梅 +藏历 チベット暦 +回历 ヒジュラ暦 +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 西暦は常に表示します。チベット暦とヒジュラ暦は日付の詳細に表示します。入梅・出梅は伝統的な暦の計算で、天気予報ではありません。 +该日期暂无藏历数据 この日付のチベット暦データはありません +该年份暂无中国法定假日与调休数据 この年の中国の祝日・振替出勤日データはありません +按传统历法推算,并非天气预报 伝統的な暦の計算であり、天気予報ではありません +年份 年 +月份 月 +上个月 前の月 +下个月 次の月 +班 勤 +休 休 +调休上班 振替出勤 +放假 休日 +春节 春節 +元宵节 元宵節 +龙头节 龍頭節 +上巳节 上巳節 +清明节 清明節 +端午节 端午節 +七夕节 七夕節 +中元节 中元節 +中秋节 中秋節 +重阳节 重陽節 +冬至节 冬至節 +腊八节 臘八節 +除夕 旧暦の大晦日 +元旦 元日 +劳动节 労働節 +国庆节 国慶節 +国庆中秋 国慶節・中秋節 +抗战胜利日 抗日戦争勝利記念日 +立春 立春 +雨水 雨水 +惊蛰 啓蟄 +春分 春分 +清明 清明 +谷雨 穀雨 +立夏 立夏 +小满 小満 +芒种 芒種 +夏至 夏至 +小暑 小暑 +大暑 大暑 +立秋 立秋 +处暑 処暑 +白露 白露 +秋分 秋分 +寒露 寒露 +霜降 霜降 +立冬 立冬 +小雪 小雪 +大雪 大雪 +冬至 冬至 +小寒 小寒 +大寒 大寒 +返回月历 月表示に戻る +控制月历格中的日干支;黄历详情始终显示年月日干支 月表示の日干支を設定します。暦の詳細には年・月・日の干支を常に表示します +宜 宜 +忌 忌 +纳音 納音 +冲煞 沖煞 +冲{} 煞{} 沖:{} 煞:{} +值神 値神 +建除十二神 建除十二直 +吉神宜趋 吉神 +今日胎神 今日の胎神 +凶神宜忌 凶神 +彭祖百忌 彭祖百忌 +二十八星宿 二十八宿 +时辰吉凶 時辰の吉凶 +子时显示当日早子时;23 点起的晚子时按次日干支计算 子の刻は当日の0時台を表示します。23時以降は翌日の干支で計算します +吉 吉 +凶 凶 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index b6ced7c..5d23556 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1247,3 +1247,92 @@ Token 活动 토큰 활동 每周 주별 {} 当周:{} Tokens {} 주: {} Tokens 截至 {} 当周累计:{} Tokens {} 주까지 누적: {} Tokens +日历 달력 +菜单栏日历 메뉴 막대 달력 +日历设置… 달력 설정… +独立显示日期,点击打开月历;不受指标合并布局影响 날짜를 독립적으로 표시합니다. 클릭하면 월 달력이 열리며 지표 통합 설정과 무관합니다 +每周开始于 한 주의 시작 +星期一 월요일 +星期日 일요일 +农历 음력 +星期 요일 +法定节假日与调休 공휴일 및 대체 근무일 +传统与公历节日 전통 및 양력 기념일 +二十四节气 24절기 +干支 간지 +三伏天 삼복 +梅雨天 입매·출매 +藏历 티베트력 +回历 이슬람력 +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 양력은 항상 표시됩니다. 티베트력과 이슬람력은 날짜 상세 정보에 표시됩니다. 입매·출매는 전통 달력 계산이며 일기 예보가 아닙니다. +该日期暂无藏历数据 이 날짜의 티베트력 데이터가 없습니다 +该年份暂无中国法定假日与调休数据 이 연도의 중국 공휴일 및 대체 근무일 데이터가 없습니다 +按传统历法推算,并非天气预报 전통 달력 계산이며 일기 예보가 아닙니다 +年份 연도 +月份 월 +上个月 이전 달 +下个月 다음 달 +班 근 +休 휴 +调休上班 대체 근무 +放假 휴일 +春节 춘절 +元宵节 원소절 +龙头节 용두절 +上巳节 상사절 +清明节 청명절 +端午节 단오절 +七夕节 칠석 +中元节 중원절 +中秋节 중추절 +重阳节 중양절 +冬至节 동지절 +腊八节 납팔절 +除夕 음력 섣달그믐 +元旦 새해 첫날 +劳动节 노동절 +国庆节 국경절 +国庆中秋 국경절·중추절 +抗战胜利日 항일 전쟁 승리 기념일 +立春 입춘 +雨水 우수 +惊蛰 경칩 +春分 춘분 +清明 청명 +谷雨 곡우 +立夏 입하 +小满 소만 +芒种 망종 +夏至 하지 +小暑 소서 +大暑 대서 +立秋 입추 +处暑 처서 +白露 백로 +秋分 추분 +寒露 한로 +霜降 상강 +立冬 입동 +小雪 소설 +大雪 대설 +冬至 동지 +小寒 소한 +大寒 대한 +返回月历 월 달력으로 돌아가기 +控制月历格中的日干支;黄历详情始终显示年月日干支 월 달력 칸의 일진 표시를 설정합니다. 황력 상세에는 연·월·일 간지가 항상 표시됩니다 +宜 길 +忌 기 +纳音 납음 +冲煞 충살 +冲{} 煞{} 충: {} · 살: {} +值神 당일 신 +建除十二神 건제십이신 +吉神宜趋 길신 +今日胎神 오늘의 태신 +凶神宜忌 흉신 +彭祖百忌 팽조백기 +二十八星宿 이십팔수 +时辰吉凶 시진 길흉 +子时显示当日早子时;23 点起的晚子时按次日干支计算 자시는 당일 00시를 표시합니다. 23시 이후의 야자시는 다음 날 간지로 계산합니다 +吉 길 +凶 흉 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 92cc033..700faf8 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1248,3 +1248,92 @@ Token 活动 Token 活動 每周 每週 {} 当周:{} Tokens {} 當週:{} Tokens 截至 {} 当周累计:{} Tokens 截至 {} 當週累計:{} Tokens +日历 日曆 +菜单栏日历 選單列日曆 +日历设置… 日曆設定… +独立显示日期,点击打开月历;不受指标合并布局影响 獨立顯示日期,點擊開啟月曆;不受指標合併佈局影響 +每周开始于 每週開始於 +星期一 星期一 +星期日 星期日 +农历 農曆 +星期 星期 +法定节假日与调休 法定節假日與調休 +传统与公历节日 傳統與公曆節日 +二十四节气 二十四節氣 +干支 干支 +三伏天 三伏天 +梅雨天 梅雨天 +藏历 藏曆 +回历 回曆 +公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 公曆始終顯示。藏曆與回曆顯示在日期詳情中;梅雨天按傳統曆法推算,並非天氣預報。 +该日期暂无藏历数据 該日期暫無藏曆資料 +该年份暂无中国法定假日与调休数据 該年份暫無中國法定假日與調休資料 +按传统历法推算,并非天气预报 按傳統曆法推算,並非天氣預報 +年份 年份 +月份 月份 +上个月 上個月 +下个月 下個月 +班 班 +休 休 +调休上班 調休上班 +放假 放假 +春节 春節 +元宵节 元宵節 +龙头节 龍頭節 +上巳节 上巳節 +清明节 清明節 +端午节 端午節 +七夕节 七夕節 +中元节 中元節 +中秋节 中秋節 +重阳节 重陽節 +冬至节 冬至節 +腊八节 臘八節 +除夕 除夕 +元旦 元旦 +劳动节 勞動節 +国庆节 國慶節 +国庆中秋 國慶中秋 +抗战胜利日 抗戰勝利日 +立春 立春 +雨水 雨水 +惊蛰 驚蟄 +春分 春分 +清明 清明 +谷雨 穀雨 +立夏 立夏 +小满 小滿 +芒种 芒種 +夏至 夏至 +小暑 小暑 +大暑 大暑 +立秋 立秋 +处暑 處暑 +白露 白露 +秋分 秋分 +寒露 寒露 +霜降 霜降 +立冬 立冬 +小雪 小雪 +大雪 大雪 +冬至 冬至 +小寒 小寒 +大寒 大寒 +返回月历 返回月曆 +控制月历格中的日干支;黄历详情始终显示年月日干支 控制月曆格中的日干支;黃曆詳情始終顯示年月日干支 +宜 宜 +忌 忌 +纳音 納音 +冲煞 沖煞 +冲{} 煞{} 沖{} 煞{} +值神 值神 +建除十二神 建除十二神 +吉神宜趋 吉神宜趨 +今日胎神 今日胎神 +凶神宜忌 凶神宜忌 +彭祖百忌 彭祖百忌 +二十八星宿 二十八星宿 +时辰吉凶 時辰吉凶 +子时显示当日早子时;23 点起的晚子时按次日干支计算 子時顯示當日早子時;23 點起的晚子時按次日干支計算 +吉 吉 +凶 凶 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index e004fc8..085bbb2 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -8,7 +8,7 @@ extension Translations { static var tables: [String] { - [english, webDAVEnglish, languagePickerEnglish] + [english, webDAVEnglish, languagePickerEnglish, calendarEnglish] } static let english = #""" diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index adbab9b..eddbcf2 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -12,6 +12,7 @@ import SwiftUI public final class AppController: NSObject, NSApplicationDelegate { private let model = AppModel() private var menuBar: MenuBarController! + private var calendarMenuBar: CalendarMenuBarController! private var mainWindow: MainWindowController! private var updateWindow: UpdateWindowController! private var speedTestWindow: SpeedTestWindowController! @@ -31,6 +32,7 @@ public final class AppController: NSObject, NSApplicationDelegate { NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu), updateAction: #selector(checkForUpdatesFromMenu)) menuBar = MenuBarController(model: model) + calendarMenuBar = CalendarMenuBarController(model: model) mainWindow = MainWindowController(model: model) mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() } updateWindow = UpdateWindowController(model: model) @@ -39,22 +41,27 @@ public final class AppController: NSObject, NSApplicationDelegate { egressWindow = EgressWindowController(model: model) egressWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() } menuBar.update() + calendarMenuBar.start() // 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置 model.openSettings = { [weak self] in self?.menuBar.dismissPopovers() + self?.calendarMenuBar.dismiss() self?.mainWindow.show(tab: .settingsGeneral) } model.openMainWindow = { [weak self] tab in self?.menuBar.dismissPopovers() + self?.calendarMenuBar.dismiss() self?.mainWindow.show(tab: tab) } model.openEgressWindow = { [weak self] in self?.menuBar.dismissPopovers() + self?.calendarMenuBar.dismiss() self?.egressWindow.show() } model.openSpeedTestWindow = { [weak self] in self?.menuBar.dismissPopovers() + self?.calendarMenuBar.dismiss() self?.speedTestWindow.show() } model.quit = { NSApp.terminate(nil) } @@ -79,6 +86,7 @@ public final class AppController: NSObject, NSApplicationDelegate { startUpdateChecks() model.alerts.openTab = { [weak self] tab in self?.menuBar.dismissPopovers() + self?.calendarMenuBar.dismiss() self?.mainWindow.show(tab: tab) } model.alerts.start() @@ -101,6 +109,13 @@ public final class AppController: NSObject, NSApplicationDelegate { } } } + if arguments.contains("--show-calendar") { + model.settings.calendarEnabled = true + calendarMenuBar.update() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.calendarMenuBar.show() + } + } if arguments.contains("--show-window") { mainWindow.show(tab: nil) } @@ -139,6 +154,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } public func applicationWillTerminate(_ notification: Notification) { + calendarMenuBar.stop() model.aiUsage.stop() model.keepAwake.releaseForTermination() if model.fans.mode != .automatic || model.keepAwake.lidClosedActive { @@ -188,6 +204,7 @@ public final class AppController: NSObject, NSApplicationDelegate { private func startUpdateChecks() { model.updates.onPrompt = { [weak self] in self?.menuBar.dismissPopovers() + self?.calendarMenuBar.dismiss() self?.updateWindow.show() } DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift new file mode 100644 index 0000000..66658a5 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift @@ -0,0 +1,65 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +@preconcurrency import Tyme4Swift + +/// 选中日期才计算的黄历值模型,不在生成 42 格月历时重复查询宜忌表。 +struct CalendarAlmanac: Identifiable { + let id: String + struct Hour: Identifiable { + let id: Int + let stem: String + let branch: String + let isLucky: Bool + let timeRange: String + } + let ganzhiSummary: String + let recommends: [String] + let avoids: [String] + let sound: String + let opposingZodiac: String + let ominousDirection: String + let twelveStar: String + let twelveStarIsLucky: Bool + let hours: [Hour] + let duty: String + let luckyGods: [String] + let unluckyGods: [String] + let fetus: String + let pengZu: [String] + let star: String + let starIsLucky: Bool +} + +extension CalendarEngine { + static func almanac(for day: CalendarDay) -> CalendarAlmanac? { + guard let solar = try? SolarDay.fromYmd(day.year, day.month, day.day) else { return nil } + let lunar = solar.getLunarDay() + let cycle = solar.getSixtyCycleDay() + let gods = cycle.gods + let star = cycle.twentyEightStar + let pengZu = cycle.sixtyCycle.pengZu + // Tyme 返回早子时、丑至亥、晚子时共 13 项。末项 23 点已用次日日柱, + // 黄历的十二格取前 12 项,避免重复子时;保留农历闰月信息,直接使用原对象。 + let hours = lunar.hours.prefix(12).enumerated().map { index, hour in + CalendarAlmanac.Hour(id: index, stem: hour.sixtyCycle.heavenStem.getName(), + branch: hour.sixtyCycle.earthBranch.getName(), + isLucky: hour.twelveStar.ecliptic.luck.index == 0, + timeRange: index == 0 ? "00:00–00:59" : String(format: "%02d:00–%02d:59", index * 2 - 1, index * 2)) + } + return CalendarAlmanac( + id: day.id, + ganzhiSummary: "\(cycle.year.getName())(\(cycle.year.earthBranch.getZodiac().getName()))年 \(cycle.month.getName())月 \(cycle.sixtyCycle.getName())日", + recommends: cycle.recommends.map { $0.getName() }, avoids: cycle.avoids.map { $0.getName() }, + sound: cycle.sixtyCycle.sound.getName(), opposingZodiac: cycle.sixtyCycle.earthBranch.getOpposite().getZodiac().getName(), + ominousDirection: cycle.sixtyCycle.earthBranch.getOminous().getName(), + twelveStar: cycle.twelveStar.getName(), twelveStarIsLucky: cycle.twelveStar.ecliptic.luck.index == 0, + hours: hours, duty: cycle.duty.getName(), + luckyGods: gods.filter { $0.luck.index == 0 }.map { $0.getName() }, + unluckyGods: gods.filter { $0.luck.index != 0 }.map { $0.getName() }, + fetus: lunar.fetusDay.getName(), + pengZu: [pengZu.pengZuHeavenStem.getName(), pengZu.pengZuEarthBranch.getName()], + star: star.getName() + star.sevenStar.getName() + star.getAnimal().getName(), starIsLucky: star.luck.index == 0) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift new file mode 100644 index 0000000..da14a40 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift @@ -0,0 +1,212 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Localization +import SwiftUI + +/// 传统黄历详情:以分格表呈现同一日期的数据,正文保留 Tyme 的传统名称。 +struct CalendarAlmanacView: View { + let day: CalendarDay + let almanac: CalendarAlmanac + let features: Set + private let heading = Color.dynamic(light: 0xAE743F, dark: 0xDBAC79) + private let lucky = Color.dynamic(light: 0x288B3B, dark: 0x63CC76) + + var body: some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(dateTitle).font(.system(size: 13)).foregroundStyle(.secondary) + if features.contains(.lunar) { + Text(day.lunarSummary).font(.system(size: 21)).foregroundStyle(.red) + } + Text(almanac.ganzhiSummary).font(.system(size: 13)).foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 10) { + advice(tr("宜"), entries: almanac.recommends, color: lucky) + advice(tr("忌"), entries: almanac.avoids, color: .red) + } + almanacTable + supplementaryDetails + } + .padding(.bottom, 6) + .textSelection(.enabled) + } + + private var dateTitle: String { + let formatter = DateFormatter() + formatter.locale = L10n.locale + formatter.calendar = CalendarEngine.gregorian() + formatter.setLocalizedDateFormatFromTemplate(features.contains(.weekdays) ? "yMMMMdEEEE" : "yMMMMd") + return formatter.string(from: day.date) + } + + private func advice(_ label: String, entries: [String], color: Color) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(label).font(.system(size: 12, weight: .medium)).foregroundStyle(.white) + .padding(4).background(color, in: Capsule()) + Text(entries.isEmpty ? tr("无") : entries.joined(separator: " ")) + .font(.system(size: 12)).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 3) + } + } + + private var almanacTable: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + tile(tr("纳音")) { Text(almanac.sound) } + verticalLine + tile(tr("冲煞")) { Text(tr("冲\(almanac.opposingZodiac) 煞\(almanac.ominousDirection)")) } + verticalLine + tile(tr("值神")) { + HStack(spacing: 4) { + Text(almanac.twelveStar) + luckLabel(almanac.twelveStarIsLucky) + } + } + } + .fixedSize(horizontal: false, vertical: true) + Divider() + hourTable + Divider() + HStack(spacing: 0) { + sideTile(tr("建除十二神"), value: almanac.duty) + verticalLine + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + tile(tr("吉神宜趋")) { Text(almanac.luckyGods.joined(separator: " ")) } + verticalLine + tile(tr("今日胎神")) { Text(almanac.fetus) } + verticalLine + tile(tr("凶神宜忌")) { Text(almanac.unluckyGods.joined(separator: " ")) } + } + .fixedSize(horizontal: false, vertical: true) + Divider() + tile(tr("彭祖百忌")) { Text(almanac.pengZu.joined(separator: "\n")) } + } + verticalLine + sideTile(tr("二十八星宿"), value: almanac.star, isLucky: almanac.starIsLucky) + } + .fixedSize(horizontal: false, vertical: true) + } + .overlay { Rectangle().strokeBorder(DS.Palette.border, lineWidth: 1) } + } + + private func sideTile(_ title: String, value: String, isLucky: Bool? = nil) -> some View { + Group { + if L10n.language.isChinese { + HStack(spacing: 6) { + Text(title.map(String.init).joined(separator: "\n")) + .font(.system(size: 16)).foregroundStyle(heading) + VStack(spacing: 5) { + Text(value.map(String.init).joined(separator: "\n")).foregroundStyle(.secondary) + if let isLucky { luckLabel(isLucky) } + } + .font(.system(size: 12)) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(title + " " + value + (isLucky.map { " " + tr($0 ? "吉" : "凶") } ?? "")) + } else { + VStack(spacing: 8) { + Text(title).font(.system(size: 13)).foregroundStyle(heading) + Text(value).font(.system(size: 12)).foregroundStyle(.secondary) + if let isLucky { luckLabel(isLucky) } + } + } + } + .multilineTextAlignment(.center) + .frame(width: L10n.language.isChinese ? 52 : 72) + .padding(.horizontal, 5) + .padding(.vertical, 10) + } + + private var hourTable: some View { + HStack(spacing: 0) { + Text(tr("时辰吉凶")) + .font(.system(size: 15)).foregroundStyle(heading) + .multilineTextAlignment(.center) + .frame(width: L10n.language.isChinese ? 42 : 60).padding(.horizontal, 6) + .help(tr("子时显示当日早子时;23 点起的晚子时按次日干支计算")) + verticalLine + ForEach(almanac.hours) { hour in + VStack(spacing: 2) { + Text(hour.stem) + Text(hour.branch) + luckLabel(hour.isLucky, compact: true) + } + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .help(hour.timeRange) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(hour.stem)\(hour.branch) \(hour.timeRange) \(tr(hour.isLucky ? "吉" : "凶"))") + } + } + .fixedSize(horizontal: false, vertical: true) + } + + private var verticalLine: some View { + Rectangle().fill(DS.Palette.border).frame(width: 1) + } + + private func tile(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(spacing: 5) { + Text(title).font(.system(size: 16)).foregroundStyle(heading) + content().font(.system(size: 12)).foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 6) + .padding(.vertical, 10) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder private func luckLabel(_ isLucky: Bool, compact: Bool = false) -> some View { + if compact && !L10n.language.isChinese { + Image(systemName: isLucky ? "checkmark" : "xmark") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(isLucky ? lucky : .red) + .accessibilityLabel(tr(isLucky ? "吉" : "凶")) + .help(tr(isLucky ? "吉" : "凶")) + } else { + Text(tr(isLucky ? "吉" : "凶")).foregroundStyle(isLucky ? lucky : .red) + } + } + + @ViewBuilder private var supplementaryDetails: some View { + let rows = additionalDetails + if !rows.isEmpty { + VStack(alignment: .leading, spacing: 7) { + ForEach(rows) { row in + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(row.feature.title).foregroundStyle(.secondary) + Text(row.value).frame(maxWidth: .infinity, alignment: .trailing) + } + } + if features.contains(.plumRain), day.plumRain != nil { + Text(tr("按传统历法推算,并非天气预报")).font(.caption2).foregroundStyle(.secondary) + } + } + .font(.system(size: 12)) + .padding(12) + .background(DS.Palette.textSecondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) + } + } + + private var additionalDetails: [CalendarDetail] { + var rows: [CalendarDetail] = [] + if features.contains(.holidays), let holiday = day.holiday { + rows.append(.init(feature: .holidays, value: tr(holiday.name) + " · " + tr(holiday.isWork ? "调休上班" : "放假"))) + } + if features.contains(.festivals), !day.festivals.isEmpty { + rows.append(.init(feature: .festivals, value: day.festivals.map { tr($0) }.joined(separator: " · "))) + } + if features.contains(.solarTerms), let value = day.solarTerm { rows.append(.init(feature: .solarTerms, value: tr(value))) } + if features.contains(.dogDays), let value = day.dogDays { rows.append(.init(feature: .dogDays, value: value)) } + if features.contains(.plumRain), let value = day.plumRain { rows.append(.init(feature: .plumRain, value: value)) } + return rows + CalendarEngine.additionalCalendars(for: day, features: features) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift new file mode 100644 index 0000000..ba41ff8 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -0,0 +1,164 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Localization +// Tyme 1.5 使用 Swift 5;并发兼容仅限此适配层,所有访问由 MainActor 串行化。 +@preconcurrency import Tyme4Swift + +/// 公历是月历的坐标;其余信息可独立隐藏,设置以稳定 rawValue 保存和同步。 +public enum CalendarFeature: String, CaseIterable, Identifiable, Sendable { + case lunar, weekdays, holidays, festivals, solarTerms, ganzhi, dogDays, plumRain, tibetan, hijri + public var id: String { rawValue } + public static let defaults: Set = [.lunar, .weekdays, .holidays, .festivals, .solarTerms] + + var title: String { + switch self { + case .lunar: tr("农历") + case .weekdays: tr("星期") + case .holidays: tr("法定节假日与调休") + case .festivals: tr("传统与公历节日") + case .solarTerms: tr("二十四节气") + case .ganzhi: tr("干支") + case .dogDays: tr("三伏天") + case .plumRain: tr("梅雨天") + case .tibetan: tr("藏历") + case .hijri: tr("回历") + } + } +} + +struct CalendarMonth: Equatable, Hashable { + static let years = 1900...2100 + var year: Int + var month: Int + + func shifted(by delta: Int) -> Self? { + let index = year * 12 + month - 1 + delta + let result = Self(year: index / 12, month: index % 12 + 1) + return Self.years.contains(result.year) ? result : nil + } +} + +struct CalendarDay: Identifiable, Equatable { + struct Holiday: Equatable { + let name: String + let isWork: Bool + } + let date: Date + let year: Int + let month: Int + let day: Int + /// Foundation 的星期编号:1 为周日。 + let weekday: Int + let lunarLabel: String + let lunarSummary: String + let ganzhiDay: String + let ganzhiSummary: String + let festivals: [String] + let solarTerm: String? + let holiday: Holiday? + let dogDays: String? + let plumRain: String? + var id: String { String(format: "%04d-%02d-%02d", year, month, day) } + var isWeekend: Bool { weekday == 1 || weekday == 7 } + + /// 每格仅显示一条主注释,所有启用的信息在选中日期详情中保留。 + func subtitle(features: Set) -> String { + if features.contains(.festivals), let festival = festivals.first { return festival } + if features.contains(.solarTerms), let solarTerm { return solarTerm } + if features.contains(.dogDays), let dogDays { return dogDays } + if features.contains(.plumRain), let plumRain { return plumRain } + return features.contains(.lunar) ? lunarLabel : "" + } +} + +struct CalendarDetail: Identifiable { + let feature: CalendarFeature + let value: String + var isUnavailable = false + var id: CalendarFeature { feature } +} + +/// Tyme 使用可变的静态表;统一在主线程读取,不把其引用类型暴露给界面或跨线程共享。 +@MainActor +enum CalendarEngine { + static func gregorian(timeZone: TimeZone = .autoupdatingCurrent) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + static func today(at date: Date = Date(), timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? { + let parts = gregorian(timeZone: timeZone).dateComponents([.year, .month, .day], from: date) + guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil } + return self.day(year: year, month: month, day: day, timeZone: timeZone) + } + + /// 保留同月的公历选择,并用当前时区重建 Date;不能复用旧时区的绝对时间。 + static func selection(_ previous: CalendarDay?, in month: CalendarMonth, + timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? { + let day = previous?.year == month.year && previous?.month == month.month ? (previous?.day ?? 1) : 1 + return self.day(year: month.year, month: month.month, day: day, timeZone: timeZone) + } + + static func month(year: Int, month: Int, firstWeekday: Int, + timeZone: TimeZone = .autoupdatingCurrent) -> [CalendarDay] { + guard CalendarMonth.years.contains(year), (1...12).contains(month), + let first = day(year: year, month: month, day: 1, timeZone: timeZone) else { return [] } + let calendar = gregorian(timeZone: timeZone) + let weekStart = firstWeekday == 1 ? 1 : 2 + let offset = (first.weekday - weekStart + 7) % 7 + // 用日历加天而非 86400 秒;夏令时切换不能导致重复日期或漏掉日期。 + return (0..<42).compactMap { index in + guard let date = calendar.date(byAdding: .day, value: index - offset, to: first.date) else { return nil } + return today(at: date, timeZone: timeZone) + } + } + + static func day(year: Int, month: Int, day: Int, + timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? { + // 包含边缘月份的相邻日期;界面浏览限制在 1900–2100 年,避开历史历法切换歧义。 + guard (1899...2101).contains(year), (1...12).contains(month), + let solar = try? SolarDay.fromYmd(year, month, day) else { return nil } + let calendar = gregorian(timeZone: timeZone) + guard let date = calendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12)) else { return nil } + let lunar = solar.getLunarDay() + let cycle = solar.getSixtyCycleDay() + let term = solar.termDay + let festivals = [lunar.festival?.getName(), solar.festival?.getName()].compactMap { $0 } + return CalendarDay(date: date, year: year, month: month, day: day, + weekday: calendar.component(.weekday, from: date), + lunarLabel: lunar.day == 1 ? lunar.lunarMonth.getName() : lunar.getName(), + lunarSummary: lunar.description, ganzhiDay: cycle.sixtyCycle.getName(), + ganzhiSummary: "\(cycle.year.getName())年 \(cycle.month.getName())月 \(cycle.sixtyCycle.getName())日", + festivals: festivals, solarTerm: term.dayIndex == 0 ? term.solarTerm.getName() : nil, + holiday: solar.legalHoliday.map { .init(name: $0.name, isWork: $0.isWork) }, + dogDays: solar.dogDay?.description, plumRain: solar.plumRainDay?.description) + } + + /// 年份覆盖来自固定版本的数据本身;未收录年份不能把 nil 解读为“无需调休”。 + static func hasHolidayData(year: Int) -> Bool { holidayYears.contains(year) } + private static let holidayYears: Set = { + let data = Array(LegalHoliday.DATA.utf8) + return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in + guard index + 4 <= data.count else { return nil } + return Int(String(decoding: data[index..<(index + 4)], as: UTF8.self)) + }) + }() + + static func additionalCalendars(for day: CalendarDay, features: Set) -> [CalendarDetail] { + guard let solar = try? SolarDay.fromYmd(day.year, day.month, day.day) else { return [] } + var details: [CalendarDetail] = [] + if features.contains(.tibetan) { + // 上游从固定起点向前扫描,范围外部分路径使用 try!;先检查完整的公历覆盖范围。 + let supported = day.id >= "1951-01-08" && day.id <= "2051-02-11" + let value = supported ? (try? solar.getRabByungDay().description) : nil + details.append(.init(feature: .tibetan, value: value ?? tr("该日期暂无藏历数据"), isUnavailable: value == nil)) + } + if features.contains(.hijri) { + details.append(.init(feature: .hijri, value: solar.getHijriDay().description)) + } + return details + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift new file mode 100644 index 0000000..1750e0f --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift @@ -0,0 +1,136 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import Localization +import SwiftUI + +/// 独立日历入口:只按分钟刷新日期文本,关闭后移除定时器,不参与 MetricsHub 的秒级采样。 +@MainActor +final class CalendarMenuBarController: NSObject { + private let model: AppModel + private var item: NSStatusItem? + private var panel: StatusPanel? + private var timer: Timer? + private var observers: [(NotificationCenter, NSObjectProtocol)] = [] + + init(model: AppModel) { + self.model = model + super.init() + } + + func start() { + update() + observeSettings() + for (center, name) in [ + (NotificationCenter.default, Notification.Name.NSCalendarDayChanged), + (NotificationCenter.default, Notification.Name.NSSystemTimeZoneDidChange), + (NSWorkspace.shared.notificationCenter, NSWorkspace.didWakeNotification), + ] { + let token = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshTitle() } + } + observers.append((center, token)) + } + } + + func stop() { + timer?.invalidate() + timer = nil + for (center, token) in observers { center.removeObserver(token) } + observers.removeAll() + dismiss() + if let item { NSStatusBar.system.removeStatusItem(item) } + item = nil + } + + private func observeSettings() { + withObservationTracking { + _ = model.settings.calendarEnabled + _ = model.settings.calendarFeatures + _ = model.settings.calendarFirstWeekday + _ = model.settings.language + _ = model.settings.appearance + } onChange: { [weak self] in + Task { @MainActor in + self?.update() + self?.observeSettings() + } + } + } + + func update() { + guard model.settings.calendarEnabled else { + dismiss() + if let item { NSStatusBar.system.removeStatusItem(item) } + item = nil + timer?.invalidate() + timer = nil + return + } + if item == nil { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.autosaveName = "XStats.calendar" + item.button?.target = self + item.button?.action = #selector(clicked(_:)) + item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) + item.button?.image = NSImage(systemSymbolName: "calendar", accessibilityDescription: tr("日历")) + item.button?.imagePosition = .imageLeading + item.button?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + self.item = item + timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshTitle() } + } + timer?.tolerance = 2 + } + refreshTitle() + panel?.refreshHeight() + } + + private func refreshTitle() { + guard let button = item?.button else { return } + let formatter = DateFormatter() + formatter.locale = L10n.locale + formatter.calendar = CalendarEngine.gregorian() + formatter.timeZone = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate(model.settings.calendarFeatures.contains(.weekdays) ? "MdEEE" : "Md") + button.title = " " + formatter.string(from: Date()) + formatter.dateStyle = .full + button.toolTip = formatter.string(from: Date()) + } + + @objc private func clicked(_ sender: NSStatusBarButton) { + if NSApp.currentEvent?.type == .rightMouseUp, let item { + let menu = NSMenu() + menu.addItem(withTitle: tr("日历设置…"), action: #selector(openSettings), keyEquivalent: "").target = self + menu.addItem(withTitle: tr("退出 XStats"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + item.menu = menu + sender.performClick(nil) + item.menu = nil + } else { + show() + } + } + + func show() { + guard let button = item?.button, let window = button.window else { return } + if panel == nil { + let model = self.model + let panel = StatusPanel(width: CalendarPopover.width, minHeight: 380, + content: { CalendarPopover().environment(model) }, + measuring: { CalendarPopover().environment(model).environment(\.isSnapshot, true) }) + panel.onVisibilityChange = { [weak self] visible in self?.item?.button?.highlight(visible) } + self.panel = panel + } + refreshTitle() + let anchor = window.convertToScreen(button.convert(button.bounds, to: nil)) + panel?.toggle(below: anchor, on: window.screen) + } + + func dismiss() { panel?.dismiss() } + + @objc private func openSettings() { + dismiss() + model.openMainWindow(.settingsMenuBar) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift new file mode 100644 index 0000000..ba4dbd2 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -0,0 +1,250 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Localization +import SwiftUI + +/// 月历与黄历详情在同一个面板内切换;返回时保留浏览月份和选中的日期。 +struct CalendarPopover: View { + static let width: CGFloat = 560 + @Environment(AppModel.self) private var model + @Environment(\.isSnapshot) private var isSnapshot + @State private var month: CalendarMonth + @State private var selected: CalendarDay? + @State private var days: [CalendarDay] + @State private var lastTodayID: String? + @State private var showsDayDetails: Bool + @State private var almanac: CalendarAlmanac? + private let referenceDate: Date? + + init(referenceDate: Date? = nil, showsDayDetails: Bool = false) { + self.referenceDate = referenceDate + let today = CalendarEngine.today(at: referenceDate ?? Date()) + let month = CalendarMonth(year: today?.year ?? 2026, month: today?.month ?? 1) + _month = State(initialValue: month) + _selected = State(initialValue: today) + _showsDayDetails = State(initialValue: showsDayDetails) + _almanac = State(initialValue: showsDayDetails ? today.flatMap { CalendarEngine.almanac(for: $0) } : nil) + _lastTodayID = State(initialValue: today?.id) + _days = State(initialValue: CalendarEngine.month(year: month.year, month: month.month, firstWeekday: 2)) + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + let today = CalendarEngine.today(at: referenceDate ?? context.date) + VStack(spacing: 0) { + Group { + if showsDayDetails { detailHeader(today: today) } + else { header(today: today) } + } + .padding(18) + PageScroll { + VStack(spacing: 16) { + if showsDayDetails, let selected, let almanac { + CalendarAlmanacView(day: selected, almanac: almanac, features: model.settings.calendarFeatures) + } else { + calendarGrid(today: today) + } + if model.settings.calendarFeatures.contains(.holidays), !CalendarEngine.hasHolidayData(year: month.year) { + Text(tr("该年份暂无中国法定假日与调休数据")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + .environment(\.isPopover, true) + } + .onChange(of: today?.id) { _, newID in + // 正在看今天时跨日自动跟进;浏览历史日期时保留用户选择。 + if selected?.id == lastTodayID, let today { + selected = today + month = CalendarMonth(year: today.year, month: today.month) + } + lastTodayID = newID + } + } + .frame(width: Self.width) + .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) + .fixedSize(horizontal: false, vertical: isSnapshot) + .background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl)) + .appLanguageEnvironment() + .overlay { RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border) } + .onAppear { reload() } + .onChange(of: month) { _, _ in reload() } + .onChange(of: selected?.id) { _, _ in refreshAlmanac() } + .onChange(of: model.settings.calendarFirstWeekday) { _, _ in reload() } + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in reload() } + .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in + guard referenceDate == nil, let today = CalendarEngine.today() else { return } + if selected?.id == lastTodayID { goToToday(today) } + lastTodayID = today.id + } + } + + private func header(today: CalendarDay?) -> some View { + HStack(spacing: 10) { + Image(systemName: "calendar").foregroundStyle(Color.accentColor) + Picker(tr("年份"), selection: $month.year) { + ForEach(CalendarMonth.years, id: \.self) { Text(String($0)).tag($0) } + } + .labelsHidden().fixedSize() + Picker(tr("月份"), selection: $month.month) { + ForEach(1...12, id: \.self) { value in Text(monthName(value)).tag(value) } + } + .labelsHidden().fixedSize() + Spacer(minLength: 0) + Button { shift(-1) } label: { Image(systemName: "chevron.left") } + .help(tr("上个月")).accessibilityLabel(tr("上个月")) + .disabled(month.shifted(by: -1) == nil) + Button(tr("今天")) { if let today { goToToday(today) } } + Button { shift(1) } label: { Image(systemName: "chevron.right") } + .help(tr("下个月")).accessibilityLabel(tr("下个月")) + .disabled(month.shifted(by: 1) == nil) + Button { model.openMainWindow(.settingsMenuBar) } label: { Image(systemName: "gearshape") } + .help(tr("日历设置…")).accessibilityLabel(tr("日历设置…")) + } + .buttonStyle(.borderless) + } + + private func calendarGrid(today: CalendarDay?) -> some View { + let features = model.settings.calendarFeatures + let firstWeekday = model.settings.calendarFirstWeekday + return VStack(spacing: 8) { + if features.contains(.weekdays) { + HStack(spacing: 4) { + ForEach(0..<7) { offset in + let weekday = (firstWeekday - 1 + offset) % 7 + 1 + Text(weekdayName(weekday)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(weekday == 1 || weekday == 7 ? Color.red : .secondary) + .frame(maxWidth: .infinity) + } + } + .padding(.bottom, 4) + } + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 5) { + ForEach(days) { day in + CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month, + isToday: day.id == today?.id, isSelected: day.id == selected?.id) { + selected = day + if day.month != month.month, CalendarMonth.years.contains(day.year) { + month = CalendarMonth(year: day.year, month: day.month) + } + showsDayDetails = true + refreshAlmanac() + } + } + } + } + } + + private func detailHeader(today: CalendarDay?) -> some View { + HStack { + Button { + showsDayDetails = false + } label: { + Label(tr("返回月历"), systemImage: "chevron.backward") + } + Spacer() + Button(tr("今天")) { + if let today { goToToday(today) } + } + Button { model.openMainWindow(.settingsMenuBar) } label: { Image(systemName: "gearshape") } + .help(tr("日历设置…")).accessibilityLabel(tr("日历设置…")) + } + .buttonStyle(.borderless) + } + + private func refreshAlmanac() { + guard showsDayDetails, let selected, almanac?.id != selected.id else { return } + almanac = CalendarEngine.almanac(for: selected) + } + + private func reload() { + selected = CalendarEngine.selection(selected, in: month) + days = CalendarEngine.month(year: month.year, month: month.month, firstWeekday: model.settings.calendarFirstWeekday) + } + + private func shift(_ delta: Int) { + guard let next = month.shifted(by: delta) else { return } + month = next + selected = CalendarEngine.day(year: next.year, month: next.month, day: 1) + } + + private func goToToday(_ today: CalendarDay) { + month = CalendarMonth(year: today.year, month: today.month) + selected = today + } + + private func monthName(_ month: Int) -> String { + let formatter = DateFormatter() + formatter.locale = L10n.locale + formatter.calendar = CalendarEngine.gregorian() + return formatter.monthSymbols[month - 1] + } + + private func weekdayName(_ weekday: Int) -> String { + let formatter = DateFormatter() + formatter.locale = L10n.locale + formatter.calendar = CalendarEngine.gregorian() + return formatter.shortWeekdaySymbols[weekday - 1] + } +} + +private struct CalendarDayCell: View { + let day: CalendarDay + let features: Set + let isCurrentMonth: Bool + let isToday: Bool + let isSelected: Bool + let action: () -> Void + + private var holiday: CalendarDay.Holiday? { features.contains(.holidays) ? day.holiday : nil } + private var isRest: Bool { holiday.map { !$0.isWork } ?? day.isWeekend } + private var foreground: Color { isSelected ? .white : isRest ? .red : DS.Palette.textPrimary } + + var body: some View { + Button(action: action) { + VStack(spacing: 3) { + Text(String(day.day)).font(.system(size: 21, weight: isToday ? .semibold : .regular, design: .rounded)) + Text(tr(day.subtitle(features: features))) + .font(.system(size: 10)) + .lineLimit(1) + .minimumScaleFactor(0.75) + if features.contains(.ganzhi) { + Text(day.ganzhiDay).font(.system(size: 9)).opacity(0.75) + } + } + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .frame(height: 66) + .background(isSelected ? Color.accentColor : holiday != nil ? foreground.opacity(0.05) : .clear, + in: RoundedRectangle(cornerRadius: 10)) + .overlay { + if isToday && !isSelected { RoundedRectangle(cornerRadius: 10).strokeBorder(Color.accentColor, lineWidth: 1.5) } + } + .overlay(alignment: .topTrailing) { + if let holiday { + Text(tr(holiday.isWork ? "班" : "休")) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.white) + .padding(2) + .background(holiday.isWork ? Color.secondary : .red, in: RoundedRectangle(cornerRadius: 3)) + .padding(2) + } else if isToday { + Circle().fill(isSelected ? .white : Color.accentColor).frame(width: 5, height: 5).padding(5) + } + } + .opacity(isCurrentMonth ? 1 : 0.35) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel([day.id, features.contains(.lunar) ? day.lunarSummary : "", tr(day.subtitle(features: features)), + holiday.map { tr($0.isWork ? "调休上班" : "放假") } ?? "", isToday ? tr("今天") : ""] + .filter { !$0.isEmpty }.joined(separator: ", ")) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift new file mode 100644 index 0000000..c7ff15c --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift @@ -0,0 +1,48 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Localization +import SwiftUI + +struct CalendarSettings: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + SettingsGroup(caption: tr("日历")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("菜单栏日历"), subtitle: tr("独立显示日期,点击打开月历;不受指标合并布局影响"), icon: "calendar") { + DSToggle(isOn: $settings.calendarEnabled, label: tr("菜单栏日历")) + } + } + if settings.calendarEnabled { + GroupRow { + SettingRow(title: tr("每周开始于")) { + Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) { + Text(tr("星期一")).tag(2) + Text(tr("星期日")).tag(1) + } + .labelsHidden() + .fixedSize() + } + } + ForEach(CalendarFeature.allCases) { feature in + GroupRow { + SettingRow(title: feature.title, subtitle: feature == .ganzhi ? tr("控制月历格中的日干支;黄历详情始终显示年月日干支") : nil) { + DSToggle(isOn: Binding(get: { settings.calendarFeatures.contains(feature) }, set: { enabled in + if enabled { settings.calendarFeatures.insert(feature) } + else { settings.calendarFeatures.remove(feature) } + }), label: feature.title) + } + } + } + GroupRow { + Text(tr("公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index ffb7273..b9b7808 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -285,6 +285,7 @@ struct MenuBarSettings: View { @Bindable var settings = model.settings MenuBarPreview() + CalendarSettings() SettingsGroup(caption: tr("布局")) { GroupRow(showsDivider: false) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift index add1e21..214107d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift @@ -17,6 +17,12 @@ enum SnapshotRenderer { static func run(outputDirectory: URL) async { try? FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + // 日历快照只渲染固定日期,不启动系统采样、AI 扫描或磁盘扫描。 + if CommandLine.arguments.contains("--calendar-only") { + renderCalendar(outputDirectory: outputDirectory) + return + } + let defaults = UserDefaults(suiteName: "XStats.snapshot") ?? .standard let settings = AppSettings(defaults: defaults) settings.language = L10n.language @@ -116,6 +122,29 @@ enum SnapshotRenderer { print(tr("截图已输出到 \(outputDirectory.path)")) } + private static func renderCalendar(outputDirectory: URL) { + let name = "XStats.calendarSnapshot.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: name) else { return } + defer { defaults.removePersistentDomain(forName: name) } + let settings = AppSettings(defaults: defaults) + settings.calendarEnabled = true + let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: []) + guard let september = CalendarEngine.day(year: 2026, month: 9, day: 23)?.date, + let summer = CalendarEngine.day(year: 2024, month: 6, day: 11)?.date else { return } + 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, + to: outputDirectory.appendingPathComponent("calendar-\(suffix).png")) + write(CalendarPopover(referenceDate: september, showsDayDetails: true), 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, + to: outputDirectory.appendingPathComponent("calendar-details-\(suffix).png")) + } + } + /// 用真实的 NSHostingView 放进离屏窗口截图,渲染路径与 App 内一致 private static func write(_ view: V, model: AppModel, appearance: NSAppearance, to url: URL) { let hosting = NSHostingView(rootView: view diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 2d708d6..c103058 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -393,6 +393,17 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { public final class AppSettings { @ObservationIgnored private let defaults: UserDefaults + /// 日历独立于性能指标,即使指标合并也保留单独入口;旧用户默认关闭。 + public var calendarEnabled: Bool { + didSet { defaults.set(calendarEnabled, forKey: Keys.calendarEnabled) } + } + public var calendarFeatures: Set { + didSet { defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) } + } + public var calendarFirstWeekday: Int { + didSet { defaults.set(calendarFirstWeekday, forKey: Keys.calendarFirstWeekday) } + } + public var menuBarItems: Set { didSet { defaults.set(menuBarItems.map(\.rawValue).sorted(), forKey: Keys.menuBarItems) } } @@ -545,6 +556,10 @@ public final class AppSettings { public init(defaults: UserDefaults = .standard) { self.defaults = defaults + calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled) + calendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures) + .map { Set($0.compactMap(CalendarFeature.init(rawValue:))) } ?? CalendarFeature.defaults + calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2 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 @@ -638,6 +653,9 @@ public final class AppSettings { } private enum Keys { + static let calendarEnabled = "calendarEnabled" + static let calendarFeatures = "calendarFeatures" + static let calendarFirstWeekday = "calendarFirstWeekday" static let menuBarItems = "menuBarItems" static let menuBarStyle = "menuBarStyle" static let networkStyle = "networkStyle" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index e7006da..12908f0 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -15,6 +15,9 @@ import WebDAVSync /// 应用时逐项校验,不认识的值跳过 public struct SettingsDocument: Codable, Equatable, Sendable { public var schema: Int? = 1 + public var calendarEnabled: Bool? + public var calendarFeatures: [String]? + public var calendarFirstWeekday: Int? public var menuBarItems: [String]? public var menuBarStyle: String? public var networkStyle: String? @@ -49,6 +52,9 @@ extension AppSettings { /// 显式列出可同步的偏好;WebDAV 地址、用户名和密码不会进入备份。 public func exportDocument() -> SettingsDocument { var doc = SettingsDocument() + doc.calendarEnabled = calendarEnabled + doc.calendarFeatures = calendarFeatures.map(\.rawValue).sorted() + doc.calendarFirstWeekday = calendarFirstWeekday doc.menuBarItems = menuBarItems.map(\.rawValue).sorted() doc.menuBarStyle = menuBarStyle.rawValue doc.networkStyle = networkStyle.rawValue @@ -88,6 +94,9 @@ extension AppSettings { value.flatMap { options.contains($0) ? $0 : nil } } + assign(\.calendarEnabled, doc.calendarEnabled) + assign(\.calendarFeatures, doc.calendarFeatures.map { Set($0.compactMap(CalendarFeature.init(rawValue:))) }) + assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: [1, 2])) 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/XStatsUITests/CalendarAlmanacTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift new file mode 100644 index 0000000..f933f2f --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift @@ -0,0 +1,52 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Testing +@testable import XStatsUI + +@MainActor +struct CalendarAlmanacTests { + @Test func referenceDateMatchesAlmanacFields() throws { + let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23)) + let detail = try #require(CalendarEngine.almanac(for: day)) + #expect(detail.sound == "壁上土") + #expect(detail.opposingZodiac == "马") + #expect(detail.ominousDirection == "南") + #expect(detail.twelveStar == "司命") + #expect(detail.twelveStarIsLucky) + #expect(detail.duty == "平") + #expect(detail.fetus == "占碓磨 房内南") + #expect(detail.pengZu == ["庚不经络织机虚张", "子不问卜自惹祸殃"]) + #expect(detail.star == "箕水豹") + #expect(detail.starIsLucky) + #expect(detail.ganzhiSummary == "丙午(马)年 丁酉月 庚子日") + } + + @Test func recommendationsAndAvoidancesComeFromTyme() throws { + let day = try #require(CalendarEngine.day(year: 2024, month: 6, day: 26)) + let detail = try #require(CalendarEngine.almanac(for: day)) + #expect(detail.recommends == ["嫁娶", "祭祀", "理发", "作灶", "修饰垣墙", "平治道涂", "整手足甲", "沐浴", "冠笄"]) + #expect(detail.avoids == ["破土", "出行", "栽种"]) + #expect(Set(detail.luckyGods).isDisjoint(with: Set(detail.unluckyGods))) + } + + @Test func twelveHoursDoNotIncludeNextDaysLateZi() throws { + let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23)) + let detail = try #require(CalendarEngine.almanac(for: day)) + #expect(detail.hours.count == 12) + #expect(detail.hours.map(\.branch) == ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]) + #expect(detail.hours.map(\.stem) == ["丙", "丁", "戊", "己", "庚", "辛", "壬", "癸", "甲", "乙", "丙", "丁"]) + #expect(detail.hours.map(\.isLucky) == [true, true, false, true, false, false, true, false, true, true, false, false]) + #expect(Set(detail.hours.map(\.id)).count == 12) + } + + @Test(arguments: [(1900, 1, 1), (2023, 3, 22), (2100, 12, 31)]) + func supportedBoundariesAndLunarLeapMonth(_ date: (Int, Int, Int)) throws { + let day = try #require(CalendarEngine.day(year: date.0, month: date.1, day: date.2)) + let detail = try #require(CalendarEngine.almanac(for: day)) + #expect(detail.hours.count == 12) + #expect(detail.sound.isEmpty == false) + #expect(detail.pengZu.count == 2) + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift new file mode 100644 index 0000000..4967c92 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift @@ -0,0 +1,129 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Testing +@testable import XStatsUI + +@MainActor +struct CalendarTests { + let zone = TimeZone(identifier: "Asia/Shanghai")! + + @Test func monthGridIncludesLeapDayAndAdjacentMonths() throws { + let days = CalendarEngine.month(year: 2024, month: 2, firstWeekday: 2, timeZone: zone) + #expect(days.count == 42) + #expect(days.first?.id == "2024-01-29") + #expect(days.last?.id == "2024-03-10") + #expect(days.filter { $0.month == 2 }.count == 29) + let sunday = CalendarEngine.month(year: 2024, month: 2, firstWeekday: 1, timeZone: zone) + #expect(sunday.first?.id == "2024-01-28") + } + + @Test func lunarLeapMonthAndFestivalPriority() throws { + let leap = try #require(CalendarEngine.day(year: 2023, month: 3, day: 22, timeZone: zone)) + #expect(leap.lunarLabel == "闰二月") + let newYear = try #require(CalendarEngine.day(year: 2026, month: 2, day: 17, timeZone: zone)) + #expect(newYear.festivals.contains("春节")) + #expect(newYear.subtitle(features: [.lunar, .festivals]) == "春节") + #expect(newYear.subtitle(features: [.lunar]) == "正月") + #expect(newYear.subtitle(features: []) == "") + } + + @Test func restAndMakeupWorkAreDistinct() throws { + let rest = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25, timeZone: zone)) + let work = try #require(CalendarEngine.day(year: 2026, month: 9, day: 20, timeZone: zone)) + #expect(rest.holiday?.isWork == false) + #expect(work.holiday?.isWork == true) + #expect(CalendarEngine.hasHolidayData(year: 2026)) + #expect(CalendarEngine.hasHolidayData(year: 2099) == false) + } + + @Test func termsAndSeasonalBoundaries() throws { + #expect(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone)?.solarTerm == "秋分") + #expect(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone)?.solarTerm == nil) + #expect(CalendarEngine.day(year: 2024, month: 6, day: 10, timeZone: zone)?.plumRain == nil) + #expect(CalendarEngine.day(year: 2024, month: 6, day: 11, timeZone: zone)?.plumRain == "入梅第1天") + #expect(CalendarEngine.day(year: 2024, month: 7, day: 6, timeZone: zone)?.plumRain == "出梅") + #expect(CalendarEngine.day(year: 2024, month: 7, day: 7, timeZone: zone)?.plumRain == nil) + #expect(CalendarEngine.day(year: 2012, month: 8, day: 8, timeZone: zone)?.dogDays == "末伏第2天") + } + + @Test func optionalCalendarsHandleUnsupportedDates() throws { + let date = try #require(CalendarEngine.day(year: 2026, month: 5, day: 13, timeZone: zone)) + let details = CalendarEngine.additionalCalendars(for: date, features: [.hijri, .tibetan]) + #expect(details.first { $0.feature == .hijri }?.value == "1447年都尔喀尔德月26日") + #expect(details.first { $0.feature == .tibetan }?.value != nil) + let early = try #require(CalendarEngine.day(year: 1900, month: 1, day: 1, timeZone: zone)) + #expect(CalendarEngine.additionalCalendars(for: early, features: [.tibetan]).first?.isUnavailable == true) + let late = try #require(CalendarEngine.day(year: 2100, month: 1, day: 1, timeZone: zone)) + #expect(CalendarEngine.additionalCalendars(for: late, features: [.tibetan]).first?.isUnavailable == true) + #expect(CalendarEngine.additionalCalendars(for: date, features: []).isEmpty) + } + + @Test(arguments: [(1951, 1, 7, true), (1951, 1, 8, false), (2051, 2, 11, false), (2051, 2, 12, true)]) + func tibetanCoverageBoundaries(_ sample: (Int, Int, Int, Bool)) throws { + let day = try #require(CalendarEngine.day(year: sample.0, month: sample.1, day: sample.2, timeZone: zone)) + let detail = try #require(CalendarEngine.additionalCalendars(for: day, features: [.tibetan]).first) + #expect(detail.isUnavailable == sample.3) + } + + @Test func invalidDatesAndNavigationLimits() { + #expect(CalendarEngine.day(year: 2026, month: 2, day: 30, timeZone: zone) == nil) + #expect(CalendarEngine.month(year: 2026, month: 13, firstWeekday: 2, timeZone: zone).isEmpty) + #expect(CalendarMonth(year: 2026, month: 12).shifted(by: 1) == CalendarMonth(year: 2027, month: 1)) + #expect(CalendarMonth(year: 1900, month: 1).shifted(by: -1) == nil) + #expect(CalendarMonth(year: 2100, month: 12).shifted(by: 1) == nil) + } + + @Test func localDayChangesAcrossMidnightAndTimeZones() throws { + let instant = try #require(ISO8601DateFormatter().date(from: "2026-09-23T16:00:00Z")) + #expect(CalendarEngine.today(at: instant, timeZone: zone)?.id == "2026-09-24") + #expect(CalendarEngine.today(at: instant, timeZone: TimeZone(secondsFromGMT: 0)!)?.id == "2026-09-23") + let losAngeles = TimeZone(identifier: "America/Los_Angeles")! + let days = CalendarEngine.month(year: 2026, month: 3, firstWeekday: 2, timeZone: losAngeles) + #expect(Set(days.map(\.id)).count == 42) + } + + @Test func historicalSelectionKeepsCivilDateAfterTimeZoneChange() throws { + let old = try #require(CalendarEngine.day(year: 2024, month: 3, day: 22, timeZone: zone)) + let newZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let month = CalendarMonth(year: 2024, month: 3) + let selected = try #require(CalendarEngine.selection(old, in: month, timeZone: newZone)) + #expect(selected.id == old.id) + #expect(selected.lunarSummary == old.lunarSummary) + #expect(selected.date != old.date) + let parts = CalendarEngine.gregorian(timeZone: newZone).dateComponents([.day, .hour], from: selected.date) + #expect(parts.day == 22) + #expect(parts.hour == 12) + #expect(CalendarEngine.selection(old, in: CalendarMonth(year: 2024, month: 4), timeZone: newZone)?.id == "2024-04-01") + } + + @Test func preferencesPersistAndRoundTripWhileOldBackupsPreserveThem() throws { + let name = "CalendarTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let settings = AppSettings(defaults: defaults) + #expect(settings.calendarEnabled == false) + #expect(settings.calendarFeatures == CalendarFeature.defaults) + settings.calendarEnabled = true + settings.calendarFeatures = [.hijri, .ganzhi] + settings.calendarFirstWeekday = 1 + let restored = AppSettings(defaults: defaults) + #expect(restored.calendarEnabled) + #expect(restored.calendarFeatures == [.hijri, .ganzhi]) + #expect(restored.calendarFirstWeekday == 1) + let doc = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(settings.exportDocument())) + restored.calendarFeatures = [] + restored.apply(doc) + #expect(restored.calendarFeatures == [.hijri, .ganzhi]) + restored.apply(SettingsDocument()) + #expect(restored.calendarEnabled) + #expect(restored.calendarFeatures == [.hijri, .ganzhi]) + var invalid = SettingsDocument() + invalid.calendarFeatures = ["hijri", "future-feature"] + invalid.calendarFirstWeekday = 99 + restored.apply(invalid) + #expect(restored.calendarFeatures == [.hijri]) + #expect(restored.calendarFirstWeekday == 1) + } +} diff --git a/README.en.md b/README.en.md index 70a3da2..c45d307 100644 --- a/README.en.md +++ b/README.en.md @@ -29,6 +29,21 @@ All system-monitoring metrics are read on your own Mac and are never uploaded. N +## Calendar +Enable the independent date item in **Settings → Menu Bar → Calendar**. Browse months and years, +return to today, and select a day for details. Gregorian dates stay visible; lunar dates, weekdays, +festivals, solar terms, Chinese holidays and makeup workdays, sexagenary cycles, dog days and +traditional plum-rain days each have a display switch. Tibetan and Hijri details are opt-in. +The item stays separate when metrics are combined; calendar preferences are included in WebDAV backups. + +Calculations run locally using [Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift), licensed under MIT +(see [third-party notices](ThirdPartyNotices.md)). Browse 1900–2100; Tibetan data covers Gregorian +1951-01-08 through 2051-02-11. Chinese holiday data currently ends in 2026; unsupported dates show a notice. +Plum-rain days follow traditional calendar rules and are not weather forecasts. + +Click a day for its traditional almanac: favorable and avoided activities, Na Yin, clashes, day deities, twelve-hour fortunes, twelve officers, fetal deity, Peng Zu taboos and lunar mansions. Returning to the month preserves the selection. + + --- ## Install diff --git a/README.ja.md b/README.ja.md index 442eb90..157ca98 100644 --- a/README.ja.md +++ b/README.ja.md @@ -25,6 +25,19 @@ XStats は、Mac の各 CPU コア、GPU、メモリ負荷、通信速度、デ +## カレンダー +「設定 → メニューバー → カレンダー」で独立した日付表示を有効にできます。月・年の切り替え、 +今日への移動、日付の詳細表示に対応します。西暦を常に表示し、旧暦、曜日、祝祭日、中国の振替出勤日、 +二十四節気、干支、三伏、入梅・出梅は個別に切り替えられます。チベット暦とヒジュラ暦は初期設定では無効です。 +設定は WebDAV バックアップに含まれます。 + +[Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift)(MIT、[第三者ライセンス](ThirdPartyNotices.md)) +でローカル計算します。閲覧範囲は 1900〜2100 年、チベット暦のデータ範囲は西暦 1951-01-08〜2051-02-11 です。 +中国の法定休日データは現在 2026 年までで、範囲外はその旨を表示します。入梅・出梅は伝統的な暦の計算で、天気予報ではありません。 + +日付をクリックすると、宜忌、納音、沖煞、値神、時辰の吉凶、建除十二直、吉神・凶神、胎神、彭祖百忌、二十八宿の詳細を表示します。月表示に戻っても選択日は保持されます。 + + ## インストール [GitHub Releases](https://github.com/ysicing/xstats/releases) から Apple Silicon 版をダウンロードしてください。 diff --git a/README.ko.md b/README.ko.md index 9afd4e0..f7aeb0e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -25,6 +25,19 @@ XStats는 CPU 코어별 사용량, GPU, 메모리 압력, 네트워크 속도, +## 달력 +「설정 → 메뉴 막대 → 달력」에서 독립 날짜 항목을 켤 수 있습니다. 월·연도 이동, 오늘로 돌아가기, +날짜별 상세 정보를 지원합니다. 양력은 항상 표시하며 음력, 요일, 명절, 중국 공휴일과 대체 근무일, +절기, 간지, 삼복, 전통 입매·출매 날짜는 개별적으로 켜고 끌 수 있습니다. 티베트력과 이슬람력은 기본적으로 꺼져 있습니다. +달력 설정은 WebDAV 백업에 포함됩니다. + +[Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift)(MIT, [타사 라이선스](ThirdPartyNotices.md))로 +기기에서 계산합니다. 탐색 범위는 1900–2100년이며 티베트력 데이터는 양력 1951-01-08부터 2051-02-11까지입니다. +중국 공휴일 데이터는 현재 2026년까지 제공하며 범위 밖에서는 안내를 표시합니다. 전통 입매·출매 날짜는 일기 예보가 아닙니다. + +날짜를 클릭하면 길한 활동과 피할 활동, 납음, 충살, 당일 신, 열두 시진의 길흉, 건제십이신, 길신·흉신, 태신, 팽조백기, 이십팔수를 표시합니다. 월 달력으로 돌아가도 선택한 날짜는 유지됩니다. + + ## 설치 [GitHub Releases](https://github.com/ysicing/xstats/releases)에서 Apple Silicon 버전을 다운로드하세요. diff --git a/README.md b/README.md index afd4507..bbc1890 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,18 @@ XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各 +## 日历 +在「设置 → 菜单栏 → 日历」开启独立日期入口,点击查看月历。支持切换月份、年份、 +返回今天和选择日期详情;公历始终显示,农历、星期、节日、节气、调休、干支、三伏天和梅雨天可分别开关。 +藏历和回历默认关闭,开启后显示在日期详情中。日历不受指标合并布局影响,设置可随 WebDAV 备份。 + +历法由 [Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift) 在本机计算,采用 MIT 许可, +见[第三方声明](ThirdPartyNotices.md)。月历可浏览 1900–2100 年;藏历数据覆盖公历 1951-01-08 至 2051-02-11, +中国法定假日与调休数据当前收录到 2026 年,超出覆盖范围会提示。梅雨天是传统历法推算,并非天气预报。 + +点击日期可进入黄历详情,查看宜忌、纳音、冲煞、值神、十二时辰吉凶、建除十二神、吉凶神煞、胎神、彭祖百忌和二十八星宿;返回月历后保留原日期选择。 + + --- ## 安装 diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 6788928..396ad1b 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -87,3 +87,10 @@ XStats 未引入这些项目的可执行程序、Sparkle 更新模块或浏览 完整许可证文本见 [LICENSES/AI-Usage-and-OpenUsage-MIT.txt](LICENSES/AI-Usage-and-OpenUsage-MIT.txt) 与 [LICENSES/usage-bar-BSD-2-Clause.txt](LICENSES/usage-bar-BSD-2-Clause.txt)。 + +## Tyme4Swift + +日历功能通过 Swift Package Manager 使用 [6tail/tyme4swift](https://github.com/6tail/tyme4swift) +1.5.0(MIT License,Copyright (c) 2026 6tail),提供公历、农历、藏历、回历、干支、 +节气、节日、调休、三伏与传统梅雨日期计算。未修改上游源码。 +完整许可见 [LICENSES/Tyme4Swift-MIT.txt](LICENSES/Tyme4Swift-MIT.txt)。