diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs new file mode 100644 index 0000000..5e97add --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class ActivityInsightsTests +{ + private static int[] Hours(params (int Hour, int Count)[] values) + { + var hours = new int[24]; + foreach (var (hour, count) in values) hours[hour] = count; + return hours; + } + + [TestMethod] + public void Hourly_NoDays_ReturnsEmptyResult() + { + var result = HourlyActivityAnalyzer.Analyze(Array.Empty()); + + Assert.AreEqual(0, result.DayCount); + Assert.AreEqual(-1, result.PeakHour); + Assert.AreEqual(Chronotype.NotEnoughData, result.Chronotype); + } + + [TestMethod] + public void Hourly_AveragesAcrossDaysAndFindsEarliestPeak() + { + var result = HourlyActivityAnalyzer.Analyze(new[] + { + Hours((9, 100), (14, 300)), + Hours((9, 300), (14, 100)), + new int[24] + }); + + Assert.AreEqual(3, result.DayCount); + Assert.AreEqual(400.0 / 3, result.HourlyAverages[9], 1e-9); + Assert.AreEqual(9, result.PeakHour); + } + + [TestMethod] + public void Hourly_BelowMinimum_NoChronotypeButNightRatio() + { + var result = HourlyActivityAnalyzer.Analyze(new[] { Hours((23, 50), (10, 50)) }); + + Assert.AreEqual(Chronotype.NotEnoughData, result.Chronotype); + Assert.AreEqual(0.5, result.NightRatio, 1e-9); + } + + [TestMethod] + [DataRow(23, 100, 14, 400, Chronotype.NightOwl)] + [DataRow(8, 300, 14, 200, Chronotype.EarlyBird)] + [DataRow(8, 100, 15, 300, Chronotype.AfternoonPerson)] + [DataRow(20, 300, 15, 100, Chronotype.EveningPerson)] + [DataRow(8, 200, 15, 200, Chronotype.EarlyBird)] + public void Hourly_Chronotype(int hourA, int countA, int hourB, int countB, Chronotype expected) + { + var result = HourlyActivityAnalyzer.Analyze(new[] { Hours((hourA, countA), (hourB, countB)) }); + + Assert.AreEqual(expected, result.Chronotype); + } + + [TestMethod] + public void Hourly_HasHourlyData_UsesKeysAndClicks() + { + Assert.IsFalse(HourlyActivityAnalyzer.HasHourlyData(new DailyStats(DateTime.Today))); + var clicksOnly = new DailyStats(DateTime.Today) { HourlyClicks = Hours((3, 1)) }; + Assert.IsTrue(HourlyActivityAnalyzer.HasHourlyData(clicksOnly)); + var legacy = new DailyStats(DateTime.Today) { HourlyKeyPresses = null!, HourlyClicks = new int[5] }; + Assert.IsFalse(HourlyActivityAnalyzer.HasHourlyData(legacy)); + } + + [TestMethod] + public void DailyStats_SumHourly_HandlesLegacyArrays() + { + var sum = DailyStats.SumHourly(null, new[] { 1, 2, -3 }); + + Assert.AreEqual(24, sum.Length); + CollectionAssert.AreEqual(new[] { 1, 2, 0 }, sum.Take(3).ToArray()); + } + + private static DailyStats Day(DateTime date, int keys, int clicks = 0, double kps = 0, double mouse = 0) + { + return new DailyStats(date) { KeyPresses = keys, LeftClicks = clicks, PeakKPS = kps, MouseDistance = mouse }; + } + + private static AchievementProgress Get(IReadOnlyList list, string id) + { + return list.Single(p => p.Definition.Id == id); + } + + [TestMethod] + public void Achievements_RecordFirstReachedDate() + { + var start = new DateTime(2026, 1, 1); + var days = Enumerable.Range(0, 12).Select(i => Day(start.AddDays(i), 10_000)).ToList(); + + var result = AchievementEvaluator.Evaluate(days, AppSettings.DefaultMouseMetersPerPixel); + + Assert.AreEqual(start, Get(result, "day_10k").ReachedOn); + Assert.AreEqual(start.AddDays(9), Get(result, "keys_100k").ReachedOn); + Assert.AreEqual(start.AddDays(6), Get(result, "streak_7").ReachedOn); + Assert.IsFalse(Get(result, "keys_1m").IsUnlocked); + Assert.AreEqual(120_000, Get(result, "keys_100k").Current); + } + + [TestMethod] + public void Achievements_StreakBreaksOnGapAndInactiveDay() + { + var start = new DateTime(2026, 3, 1); + var days = new List(); + days.AddRange(Enumerable.Range(0, 5).Select(i => Day(start.AddDays(i), 10))); + days.Add(Day(start.AddDays(5), 0)); // 不活跃 + days.AddRange(Enumerable.Range(6, 4).Select(i => Day(start.AddDays(i), 10))); + days.AddRange(Enumerable.Range(12, 6).Select(i => Day(start.AddDays(i), 10))); // 中间缺两天 + + var result = AchievementEvaluator.Evaluate(days, AppSettings.DefaultMouseMetersPerPixel); + + Assert.AreEqual(6, Get(result, "streak_7").Current); + Assert.IsFalse(Get(result, "streak_7").IsUnlocked); + Assert.AreEqual(15, Get(result, "active_100").Current); + } + + [TestMethod] + public void Achievements_MarathonUsesCalibrationAndKpsUsesDailyPeak() + { + var date = new DateTime(2026, 5, 1); + // 0.5 米每像素:42,000 + 42,390 像素 = 42,195 米,恰好达到马拉松距离 + var result = AchievementEvaluator.Evaluate(new[] + { + Day(date, 1, mouse: 42_000, kps: 9.9), + Day(date.AddDays(1), 1, mouse: 42_390, kps: 10) + }, 0.5); + + Assert.AreEqual(date.AddDays(1), Get(result, "marathon").ReachedOn); + Assert.AreEqual(date.AddDays(1), Get(result, "kps_10").ReachedOn); + Assert.IsFalse(Get(result, "kps_15").IsUnlocked); + } + + [TestMethod] + public void Achievements_CurrentStreakStartsFromYesterdayWhenTodayInactive() + { + var today = new DateTime(2026, 10, 23); + var byDate = new Dictionary + { + [today.AddDays(-1)] = Day(today.AddDays(-1), 5), + [today.AddDays(-2)] = Day(today.AddDays(-2), 0, clicks: 3), + [today.AddDays(-4)] = Day(today.AddDays(-4), 5) + }; + + Assert.AreEqual(2, AchievementEvaluator.CurrentStreak(byDate, today)); + byDate[today] = Day(today, 1); + Assert.AreEqual(3, AchievementEvaluator.CurrentStreak(byDate, today)); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs new file mode 100644 index 0000000..dc7feac --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Linq; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class KeyboardPersonaAnalyzerTests +{ + private static KeyboardPersonaResult Analyze(Dictionary counts, int clicks) + { + return KeyboardPersonaAnalyzer.Analyze(counts, counts.Values.Sum(), clicks); + } + + [TestMethod] + public void BelowMinimumKeyPresses_DoesNotAssignPersona() + { + var result = Analyze(new Dictionary { ["A"] = 199 }, clicks: 50); + + Assert.IsFalse(result.HasEnoughData); + Assert.AreEqual(0, result.SecondaryPersonas.Count); + } + + [TestMethod] + public void ShortcutHeavy_IsShortcutMaster_AndShiftOnlyCombosAreNotShortcuts() + { + var result = Analyze(new Dictionary + { + ["A"] = 700, + ["Shift+A"] = 150, + ["Ctrl+S"] = 80, + ["Alt+Tab"] = 40, + ["Win+D"] = 30 + }, clicks: 300); + + Assert.AreEqual(150, result.ShortcutCount); + Assert.AreEqual(KeyboardPersona.ShortcutMaster, result.Persona); + } + + [TestMethod] + public void PasteDominant_IsCopyPasteEngineer() + { + var result = Analyze(new Dictionary + { + ["A"] = 900, + ["Ctrl+C"] = 10, + ["Ctrl+V"] = 40, + ["Ctrl+Z"] = 5 + }, clicks: 400); + + Assert.AreEqual(10, result.CopyCount); + Assert.AreEqual(40, result.PasteCount); + Assert.AreEqual(5, result.UndoCount); + // 快捷键占比 55/955 < 12%,因此主称号落到复制粘贴 + Assert.AreEqual(KeyboardPersona.CopyPasteEngineer, result.Persona); + } + + [TestMethod] + public void RegretKeysIncludeModifiedBackspaceAndDelete() + { + var result = Analyze(new Dictionary + { + ["A"] = 800, + ["Backspace"] = 80, + ["Ctrl+Backspace"] = 10, + ["Delete"] = 20 + }, clicks: 400); + + Assert.AreEqual(110, result.RegretCount); + Assert.AreEqual(KeyboardPersona.Perfectionist, result.Persona); + } + + [TestMethod] + public void ArrowHeavy_IsArrowKeyWalker() + { + var result = Analyze(new Dictionary + { + ["A"] = 900, + ["Up"] = 50, + ["Shift+Down"] = 50 + }, clicks: 400); + + Assert.AreEqual(100, result.ArrowCount); + Assert.AreEqual(KeyboardPersona.ArrowKeyWalker, result.Persona); + } + + [TestMethod] + [DataRow(1000, KeyboardPersona.MousePerson)] + [DataRow(150, KeyboardPersona.KeyboardPurist)] + [DataRow(500, KeyboardPersona.BalancedPlayer)] + public void ClickRatio_DecidesMouseOrKeyboardOrBalanced(int clicks, KeyboardPersona expected) + { + var result = Analyze(new Dictionary { ["A"] = 1000 }, clicks); + + Assert.AreEqual(expected, result.Persona); + } + + [TestMethod] + public void MultipleMatches_FirstIsPrimaryRestAreSecondary() + { + var result = Analyze(new Dictionary + { + ["A"] = 600, + ["Ctrl+S"] = 200, + ["Backspace"] = 200 + }, clicks: 50); + + Assert.AreEqual(KeyboardPersona.ShortcutMaster, result.Persona); + CollectionAssert.AreEqual( + new[] { KeyboardPersona.Perfectionist, KeyboardPersona.KeyboardPurist }, + result.SecondaryPersonas.ToArray()); + } + + [TestMethod] + public void TopKeys_ExcludeSpace() + { + var result = Analyze(new Dictionary + { + ["Space"] = 500, + ["E"] = 300, + ["T"] = 200, + ["A"] = 100, + ["Q"] = 1 + }, clicks: 400); + + CollectionAssert.AreEqual(new[] { "E", "T", "A" }, result.TopKeys.Select(k => k.Key).ToArray()); + } +} diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index d70f237..ec6936e 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -39,6 +39,7 @@ public partial class App : System.Windows.Application private AppStatsWindow? _appStatsWindow; private KeyboardHeatmapWindow? _keyboardHeatmapWindow; private KeyHistoryWindow? _keyHistoryWindow; + private YearReviewWindow? _yearReviewWindow; private SyncSettingsWindow? _syncSettingsWindow; private FloatingStatsWindow? _floatingStatsWindow; private MenuItem? _floatingStatsMenuItem; @@ -126,6 +127,7 @@ protected override void OnStartup(StartupEventArgs e) _syncCoordinator.Start(); InitializeAnalytics(statsManager); InputMonitorService.Instance.StartMonitoring(); + AchievementService.Instance.Start(); Console.WriteLine("Creating tray icon..."); _trayIconViewModel = new TrayIconViewModel(); @@ -227,6 +229,14 @@ private System.Windows.Controls.ContextMenu CreateContextMenu() }; menu.Items.Add(keyHistoryItem); + var yearReviewItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_YearReview }; + yearReviewItem.Click += (s, e) => + { + TrackClick("context_menu_year_review"); + ShowYearReviewWindow(); + }; + menu.Items.Add(yearReviewItem); + menu.Items.Add(new System.Windows.Controls.Separator()); var quitItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_Quit }; @@ -475,6 +485,20 @@ public void ShowKeyHistoryWindow() _keyHistoryWindow.Activate(); } + public void ShowYearReviewWindow() + { + if (_yearReviewWindow != null && _yearReviewWindow.IsVisible) + { + _yearReviewWindow.Activate(); + return; + } + + _yearReviewWindow = new YearReviewWindow(); + _yearReviewWindow.Closed += (_, _) => _yearReviewWindow = null; + _yearReviewWindow.Show(); + _yearReviewWindow.Activate(); + } + public void ExportData() { // 确保在 UI 线程上执行 @@ -649,6 +673,7 @@ protected override void OnExit(ExitEventArgs e) _floatingStatsWindow?.Close(); _floatingStatsWindow = null; InputMonitorService.Instance.StopMonitoring(); + AchievementService.Instance.Stop(); _syncCoordinator?.Dispose(); _syncCoordinator = null; StatsManager.Instance.Dispose(); diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index 4319165..ddfda7a 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text.Json.Serialization; namespace KeyStats.Models; @@ -118,6 +119,17 @@ public int FloatingStatsFontSize [JsonPropertyName("heatmapCustomHighColor")] public string HeatmapCustomHighColor { get; set; } = "#C62828"; + // 已解锁的成就:id → 首次达成日期(yyyy-MM-dd) + [JsonPropertyName("unlockedAchievements")] + public Dictionary UnlockedAchievements { get; set; } = new(); + + // 首次评估时静默补记已达成的成就,之后新解锁的才发通知 + [JsonPropertyName("achievementsInitialized")] + public bool AchievementsInitialized { get; set; } + + [JsonPropertyName("achievementNotificationsEnabled")] + public bool AchievementNotificationsEnabled { get; set; } = true; + [JsonPropertyName("languagePreference")] public string LanguagePreference { get; set; } = "system"; // "system" | "zh-Hans" | "zh-Hant" | "en" } diff --git a/KeyStats.Windows/KeyStats/Models/DailyStats.cs b/KeyStats.Windows/KeyStats/Models/DailyStats.cs index bc74e7d..7f4567c 100644 --- a/KeyStats.Windows/KeyStats/Models/DailyStats.cs +++ b/KeyStats.Windows/KeyStats/Models/DailyStats.cs @@ -47,6 +47,64 @@ public class DailyStats [JsonPropertyName("appStats")] public Dictionary AppStats { get; set; } = new(); + // 按本地时间小时(0-23)的计数;旧数据没有这两个字段,读出来按全 0 处理 + [JsonPropertyName("hourlyKeyPresses")] + public int[] HourlyKeyPresses { get; set; } = new int[HoursPerDay]; + + [JsonPropertyName("hourlyClicks")] + public int[] HourlyClicks { get; set; } = new int[HoursPerDay]; + + public const int HoursPerDay = 24; + + /// 返回长度固定为 24 的副本,null 或长度不对时补齐 / 截断,负数归零。 + public static int[] NormalizeHourly(int[]? source) + { + var result = new int[HoursPerDay]; + if (source == null) + { + return result; + } + + for (var hour = 0; hour < HoursPerDay && hour < source.Length; hour++) + { + result[hour] = Math.Max(0, source[hour]); + } + return result; + } + + public static int[] SumHourly(int[]? left, int[]? right) + { + var a = NormalizeHourly(left); + var b = NormalizeHourly(right); + var result = new int[HoursPerDay]; + for (var hour = 0; hour < HoursPerDay; hour++) + { + var total = (long)a[hour] + b[hour]; + result[hour] = total >= int.MaxValue ? int.MaxValue : (int)total; + } + return result; + } + + public void RecordHourlyKeyPress(int hour) + { + HourlyKeyPresses = IncrementHour(HourlyKeyPresses, hour); + } + + public void RecordHourlyClick(int hour) + { + HourlyClicks = IncrementHour(HourlyClicks, hour); + } + + private static int[] IncrementHour(int[]? buckets, int hour) + { + var target = buckets != null && buckets.Length == HoursPerDay ? buckets : NormalizeHourly(buckets); + if (hour >= 0 && hour < HoursPerDay && target[hour] < int.MaxValue) + { + target[hour]++; + } + return target; + } + [JsonIgnore] public int TotalClicks => LeftClicks + RightClicks + MiddleClicks + SideBackClicks + SideForwardClicks; diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs index b359298..3307467 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -66,6 +66,108 @@ public static class Strings public static string Settings_HeatmapCustomHigh => Get(nameof(Settings_HeatmapCustomHigh)); public static string Settings_HeatmapPreviewLess => Get(nameof(Settings_HeatmapPreviewLess)); public static string Settings_HeatmapPreviewMore => Get(nameof(Settings_HeatmapPreviewMore)); + public static string YearReview_WindowTitle => Get(nameof(YearReview_WindowTitle)); + public static string YearReview_HeaderTitle => Get(nameof(YearReview_HeaderTitle)); + public static string YearReview_HeaderSubtitle => Get(nameof(YearReview_HeaderSubtitle)); + public static string YearReview_TabWall => Get(nameof(YearReview_TabWall)); + public static string YearReview_TabPersona => Get(nameof(YearReview_TabPersona)); + public static string YearReview_RangePast12Months => Get(nameof(YearReview_RangePast12Months)); + public static string YearReview_RangeYearFormat => Get(nameof(YearReview_RangeYearFormat)); + public static string YearReview_MetricKeys => Get(nameof(YearReview_MetricKeys)); + public static string YearReview_MetricClicks => Get(nameof(YearReview_MetricClicks)); + public static string YearReview_Total => Get(nameof(YearReview_Total)); + public static string YearReview_ActiveDays => Get(nameof(YearReview_ActiveDays)); + public static string YearReview_CurrentStreak => Get(nameof(YearReview_CurrentStreak)); + public static string YearReview_LongestStreak => Get(nameof(YearReview_LongestStreak)); + public static string YearReview_BestDay => Get(nameof(YearReview_BestDay)); + public static string YearReview_DaysFormat => Get(nameof(YearReview_DaysFormat)); + public static string YearReview_ShortDateFormat => Get(nameof(YearReview_ShortDateFormat)); + public static string YearReview_TooltipDateFormat => Get(nameof(YearReview_TooltipDateFormat)); + public static string YearReview_TooltipKeysFormat => Get(nameof(YearReview_TooltipKeysFormat)); + public static string YearReview_TooltipClicksFormat => Get(nameof(YearReview_TooltipClicksFormat)); + public static string YearReview_PersonaLabel => Get(nameof(YearReview_PersonaLabel)); + public static string YearReview_PersonaNotEnough => Get(nameof(YearReview_PersonaNotEnough)); + public static string YearReview_Persona_ShortcutMaster => Get(nameof(YearReview_Persona_ShortcutMaster)); + public static string YearReview_Persona_ShortcutMasterDesc => Get(nameof(YearReview_Persona_ShortcutMasterDesc)); + public static string YearReview_Persona_CopyPasteEngineer => Get(nameof(YearReview_Persona_CopyPasteEngineer)); + public static string YearReview_Persona_CopyPasteEngineerDesc => Get(nameof(YearReview_Persona_CopyPasteEngineerDesc)); + public static string YearReview_Persona_Perfectionist => Get(nameof(YearReview_Persona_Perfectionist)); + public static string YearReview_Persona_PerfectionistDesc => Get(nameof(YearReview_Persona_PerfectionistDesc)); + public static string YearReview_Persona_ArrowKeyWalker => Get(nameof(YearReview_Persona_ArrowKeyWalker)); + public static string YearReview_Persona_ArrowKeyWalkerDesc => Get(nameof(YearReview_Persona_ArrowKeyWalkerDesc)); + public static string YearReview_Persona_MousePerson => Get(nameof(YearReview_Persona_MousePerson)); + public static string YearReview_Persona_MousePersonDesc => Get(nameof(YearReview_Persona_MousePersonDesc)); + public static string YearReview_Persona_KeyboardPurist => Get(nameof(YearReview_Persona_KeyboardPurist)); + public static string YearReview_Persona_KeyboardPuristDesc => Get(nameof(YearReview_Persona_KeyboardPuristDesc)); + public static string YearReview_Persona_BalancedPlayer => Get(nameof(YearReview_Persona_BalancedPlayer)); + public static string YearReview_Persona_BalancedPlayerDesc => Get(nameof(YearReview_Persona_BalancedPlayerDesc)); + public static string YearReview_MetricShortcutRatio => Get(nameof(YearReview_MetricShortcutRatio)); + public static string YearReview_MetricShortcutDetailFormat => Get(nameof(YearReview_MetricShortcutDetailFormat)); + public static string YearReview_MetricRegret => Get(nameof(YearReview_MetricRegret)); + public static string YearReview_MetricRegretDetailFormat => Get(nameof(YearReview_MetricRegretDetailFormat)); + public static string YearReview_MetricCopyPaste => Get(nameof(YearReview_MetricCopyPaste)); + public static string YearReview_MetricCopyPasteValueFormat => Get(nameof(YearReview_MetricCopyPasteValueFormat)); + public static string YearReview_MetricUndo => Get(nameof(YearReview_MetricUndo)); + public static string YearReview_MetricArrow => Get(nameof(YearReview_MetricArrow)); + public static string YearReview_MetricArrowDetailFormat => Get(nameof(YearReview_MetricArrowDetailFormat)); + public static string YearReview_MetricClickRatio => Get(nameof(YearReview_MetricClickRatio)); + public static string YearReview_MetricClickRatioValueFormat => Get(nameof(YearReview_MetricClickRatioValueFormat)); + public static string YearReview_MetricTopKeys => Get(nameof(YearReview_MetricTopKeys)); + public static string YearReview_MetricTopKeysDetail => Get(nameof(YearReview_MetricTopKeysDetail)); + public static string Stats_YearReview => Get(nameof(Stats_YearReview)); + public static string Tray_YearReview => Get(nameof(Tray_YearReview)); + public static string YearReview_PersonaTotalsFormat => Get(nameof(YearReview_PersonaTotalsFormat)); + public static string YearReview_TabHourly => Get(nameof(YearReview_TabHourly)); + public static string YearReview_TabAchievements => Get(nameof(YearReview_TabAchievements)); + public static string Hourly_PeakHour => Get(nameof(Hourly_PeakHour)); + public static string Hourly_Chronotype => Get(nameof(Hourly_Chronotype)); + public static string Hourly_NightRatio => Get(nameof(Hourly_NightRatio)); + public static string Hourly_DayCount => Get(nameof(Hourly_DayCount)); + public static string Hourly_DayCountDetail => Get(nameof(Hourly_DayCountDetail)); + public static string Hourly_ChartCaption => Get(nameof(Hourly_ChartCaption)); + public static string Hourly_Empty => Get(nameof(Hourly_Empty)); + public static string Hourly_HourRangeFormat => Get(nameof(Hourly_HourRangeFormat)); + public static string Hourly_TooltipKeysFormat => Get(nameof(Hourly_TooltipKeysFormat)); + public static string Hourly_TooltipClicksFormat => Get(nameof(Hourly_TooltipClicksFormat)); + public static string Chronotype_NotEnough => Get(nameof(Chronotype_NotEnough)); + public static string Chronotype_NightOwl => Get(nameof(Chronotype_NightOwl)); + public static string Chronotype_NightOwlDesc => Get(nameof(Chronotype_NightOwlDesc)); + public static string Chronotype_EarlyBird => Get(nameof(Chronotype_EarlyBird)); + public static string Chronotype_EarlyBirdDesc => Get(nameof(Chronotype_EarlyBirdDesc)); + public static string Chronotype_AfternoonPerson => Get(nameof(Chronotype_AfternoonPerson)); + public static string Chronotype_AfternoonPersonDesc => Get(nameof(Chronotype_AfternoonPersonDesc)); + public static string Chronotype_EveningPerson => Get(nameof(Chronotype_EveningPerson)); + public static string Chronotype_EveningPersonDesc => Get(nameof(Chronotype_EveningPersonDesc)); + public static string Achievement_UnlockedSummaryFormat => Get(nameof(Achievement_UnlockedSummaryFormat)); + public static string Achievement_CurrentStreakFormat => Get(nameof(Achievement_CurrentStreakFormat)); + public static string Achievement_UnlockedOnFormat => Get(nameof(Achievement_UnlockedOnFormat)); + public static string Achievement_DateFormat => Get(nameof(Achievement_DateFormat)); + public static string Achievement_keys_100k => Get(nameof(Achievement_keys_100k)); + public static string Achievement_keys_1m => Get(nameof(Achievement_keys_1m)); + public static string Achievement_keys_10m => Get(nameof(Achievement_keys_10m)); + public static string Achievement_day_10k => Get(nameof(Achievement_day_10k)); + public static string Achievement_day_30k => Get(nameof(Achievement_day_30k)); + public static string Achievement_day_50k => Get(nameof(Achievement_day_50k)); + public static string Achievement_clicks_100k => Get(nameof(Achievement_clicks_100k)); + public static string Achievement_clicks_1m => Get(nameof(Achievement_clicks_1m)); + public static string Achievement_streak_7 => Get(nameof(Achievement_streak_7)); + public static string Achievement_streak_30 => Get(nameof(Achievement_streak_30)); + public static string Achievement_streak_100 => Get(nameof(Achievement_streak_100)); + public static string Achievement_active_100 => Get(nameof(Achievement_active_100)); + public static string Achievement_active_365 => Get(nameof(Achievement_active_365)); + public static string Achievement_kps_10 => Get(nameof(Achievement_kps_10)); + public static string Achievement_kps_15 => Get(nameof(Achievement_kps_15)); + public static string Achievement_marathon => Get(nameof(Achievement_marathon)); + public static string Achievement_Desc_TotalKeys => Get(nameof(Achievement_Desc_TotalKeys)); + public static string Achievement_Desc_DailyKeys => Get(nameof(Achievement_Desc_DailyKeys)); + public static string Achievement_Desc_TotalClicks => Get(nameof(Achievement_Desc_TotalClicks)); + public static string Achievement_Desc_Streak => Get(nameof(Achievement_Desc_Streak)); + public static string Achievement_Desc_ActiveDays => Get(nameof(Achievement_Desc_ActiveDays)); + public static string Achievement_Desc_PeakKps => Get(nameof(Achievement_Desc_PeakKps)); + public static string Achievement_Desc_MouseDistance => Get(nameof(Achievement_Desc_MouseDistance)); + public static string Notif_AchievementUnlockedFormat => Get(nameof(Notif_AchievementUnlockedFormat)); + public static string NotifSettings_AchievementEnable => Get(nameof(NotifSettings_AchievementEnable)); + public static string NotifSettings_AchievementDesc => Get(nameof(NotifSettings_AchievementDesc)); public static string Settings_Language => Get(nameof(Settings_Language)); public static string Settings_LanguageDescription => Get(nameof(Settings_LanguageDescription)); public static string Settings_Language_System => Get(nameof(Settings_Language_System)); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx index 8a008a5..8546ba6 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -123,6 +123,108 @@ High color Less More + Year in Review - KeyStats + Year in Review + Your daily activity wall and keyboard personality. + Activity Wall + Keyboard Personality + Past 12 months + {0} + Key presses + Clicks + Total + Active days + Current streak + Longest streak + Best day + {0:N0} days + MMM d + ddd, MMM d, yyyy + {0} · {1:N0} key presses + {0} · {1:N0} clicks + Your keyboard personality + Not enough data yet. Type a bit more and check back. + Shortcut Master + Hands never leave the keyboard; the mouse is just decoration. + Copy-Paste Engineer + A mover of code and an artist of efficiency. + Perfectionist + Every word gets weighed again and again. + Arrow Key Walker + The cursor dances at your fingertips. + Mouse Person + Click, click, click: everything under control. + Keyboard Purist + If the keyboard can do it, the mouse stays put. + Balanced Player + Keyboard and mouse in perfect balance. + Shortcut ratio + {0:N0} shortcuts + Regret index + Backspace and Delete: {0:N0} + Copy / Paste + Copy {0:N0} · Paste {1:N0} + Undo + Arrow keys + {0:N0} presses + Clicks vs keys + {0:N0} clicks per 100 keys + Top keys + Space excluded + Year in Review + Year in Review + Based on {0:N0} key presses · {1:N0} clicks + Active Hours + Achievements + Peak hour + Chronotype + Night share + Days tracked + Days with hourly data + Average per day, by hour + Hourly tracking starts now. Check back in a while. + {0:00}:00–{1:00}:00 + {0} · {1:N0} key presses per day + {0} · {1:N0} clicks per day + Not enough data yet + Night Owl + Wide awake after dark; ideas arrive at night. + Early Bird + The morning is where the magic happens. + Afternoon Person + Full power right after lunch. + Evening Person + The real work starts after hours. + {0} of {1} unlocked + Current streak: {0:N0} days + Unlocked {0} + MMM d, yyyy + Warming Up + Millionaire + Legend + 10K Day + Keyboard Storm + Human Typewriter + Click Expert + Click Harvester + Week Streak + Month Streak + 100-Day Streak + 100 Active Days + Year-Round + Quick Fingers + Lightning Fingers + Mouse Marathon + Reach {0} total key presses + Press {0} keys in a single day + Reach {0} total clicks + Stay active {0} days in a row + Be active on {0} days + Hit a peak of {0} keys per second + Move the mouse {0} km in total + 🏆 Achievement unlocked: {0} + Achievement notifications + Show a notification when you unlock an achievement Restart Required KeyStats needs to restart to apply the new language. Restart now? diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index 42b4f4e..bc63b5e 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -123,6 +123,108 @@ 最高色 少 多 + 年度回顾 - KeyStats + 年度回顾 + 每天的活跃贡献墙,以及你的键盘人格。 + 贡献墙 + 键盘人格 + 过去 12 个月 + {0} 年 + 按键次数 + 鼠标点击 + 总计 + 活跃天数 + 当前连续 + 最长连续 + 最高单日 + {0:N0} 天 + M月d日 + yyyy年M月d日 ddd + {0} · {1:N0} 次按键 + {0} · {1:N0} 次点击 + 你的键盘人格 + 数据还不够,多敲一会儿再来看看吧 + 快捷键大师 + 手不离键盘,鼠标只是摆设 + 复制粘贴工程师 + 代码的搬运工,效率的艺术家 + 完美主义者 + 每个字都要反复斟酌 + 方向键行者 + 光标在指尖上下翻飞 + 鼠标流 + 点点点,一切尽在掌握 + 键盘流 + 能用键盘解决的绝不碰鼠标 + 均衡型选手 + 键鼠双修,张弛有度 + 快捷键占比 + 共 {0:N0} 次组合键 + 后悔指数 + 退格和删除共 {0:N0} 次 + 复制 / 粘贴 + 复制 {0:N0} · 粘贴 {1:N0} + 撤销次数 + 方向键占比 + 共 {0:N0} 次 + 键鼠比 + 每 100 次按键点击 {0:N0} 次 + 最常用的键 + 不含空格 + 年度回顾 + 年度回顾 + 基于 {0:N0} 次按键 · {1:N0} 次点击 + 活跃时段 + 成就 + 最活跃时段 + 作息类型 + 夜间占比 + 统计天数 + 只算有小时记录的天 + 每小时日均值 + 从现在开始按小时记录,过一会儿再来看看吧 + {0:00}:00–{1:00}:00 + {0} · 日均 {1:N0} 次按键 + {0} · 日均 {1:N0} 次点击 + 数据还不够 + 夜猫子 + 越夜越清醒,灵感都在深夜 + 早鸟 + 一日之计在于晨 + 午后型 + 午饭过后火力全开 + 晚间型 + 下班后才是正经时间 + 已解锁 {0} / {1} + 当前连续活跃 {0:N0} 天 + {0} 解锁 + yyyy年M月d日 + 初露锋芒 + 百万键客 + 千万传奇 + 日敲万键 + 键盘风暴 + 人形打字机 + 点击达人 + 鼠标收割机 + 一周不断 + 月度坚持 + 百日筑基 + 百日老友 + 年度伙伴 + 快手 + 闪电手 + 鼠标马拉松 + 累计按键达到 {0} 次 + 单日按键达到 {0} 次 + 累计点击达到 {0} 次 + 连续活跃 {0} 天 + 累计活跃 {0} 天 + 峰值手速达到 {0} 键/秒 + 鼠标累计移动 {0} 公里 + 🏆 解锁成就:{0} + 成就解锁通知 + 解锁新成就时弹出系统通知 需要重启 需要重启 KeyStats 才能切换语言。是否立即重启? diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index 655f800..ac954ff 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -123,6 +123,108 @@ 最高色 少 多 + 年度回顧 - KeyStats + 年度回顧 + 每天的活躍貢獻牆,以及你的鍵盤人格。 + 貢獻牆 + 鍵盤人格 + 過去 12 個月 + {0} 年 + 按鍵次數 + 滑鼠點擊 + 總計 + 活躍天數 + 目前連續 + 最長連續 + 最高單日 + {0:N0} 天 + M月d日 + yyyy年M月d日 ddd + {0} · {1:N0} 次按鍵 + {0} · {1:N0} 次點擊 + 你的鍵盤人格 + 資料還不夠,多敲一會兒再來看看吧 + 快捷鍵大師 + 手不離鍵盤,滑鼠只是擺設 + 複製貼上工程師 + 程式碼的搬運工,效率的藝術家 + 完美主義者 + 每個字都要反覆斟酌 + 方向鍵行者 + 游標在指尖上下翻飛 + 滑鼠流 + 點點點,一切盡在掌握 + 鍵盤流 + 能用鍵盤解決的絕不碰滑鼠 + 均衡型選手 + 鍵鼠雙修,張弛有度 + 快捷鍵佔比 + 共 {0:N0} 次組合鍵 + 後悔指數 + 退格和刪除共 {0:N0} 次 + 複製 / 貼上 + 複製 {0:N0} · 貼上 {1:N0} + 復原次數 + 方向鍵佔比 + 共 {0:N0} 次 + 鍵鼠比 + 每 100 次按鍵點擊 {0:N0} 次 + 最常用的鍵 + 不含空白鍵 + 年度回顧 + 年度回顧 + 基於 {0:N0} 次按鍵 · {1:N0} 次點擊 + 活躍時段 + 成就 + 最活躍時段 + 作息類型 + 夜間佔比 + 統計天數 + 只算有小時記錄的天 + 每小時日均值 + 從現在開始按小時記錄,過一會兒再來看看吧 + {0:00}:00–{1:00}:00 + {0} · 日均 {1:N0} 次按鍵 + {0} · 日均 {1:N0} 次點擊 + 資料還不夠 + 夜貓子 + 越夜越清醒,靈感都在深夜 + 早鳥 + 一日之計在於晨 + 午後型 + 午飯過後火力全開 + 晚間型 + 下班後才是正經時間 + 已解鎖 {0} / {1} + 目前連續活躍 {0:N0} 天 + {0} 解鎖 + yyyy年M月d日 + 初露鋒芒 + 百萬鍵客 + 千萬傳奇 + 日敲萬鍵 + 鍵盤風暴 + 人形打字機 + 點擊達人 + 滑鼠收割機 + 一週不斷 + 月度堅持 + 百日築基 + 百日老友 + 年度夥伴 + 快手 + 閃電手 + 滑鼠馬拉松 + 累計按鍵達到 {0} 次 + 單日按鍵達到 {0} 次 + 累計點擊達到 {0} 次 + 連續活躍 {0} 天 + 累計活躍 {0} 天 + 峰值手速達到 {0} 鍵/秒 + 滑鼠累計移動 {0} 公里 + 🏆 解鎖成就:{0} + 成就解鎖通知 + 解鎖新成就時彈出系統通知 需要重新啟動 KeyStats 需要重新啟動才能套用新的語言。要立即重新啟動嗎? diff --git a/KeyStats.Windows/KeyStats/Services/AchievementEvaluator.cs b/KeyStats.Windows/KeyStats/Services/AchievementEvaluator.cs new file mode 100644 index 0000000..afec383 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/AchievementEvaluator.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeyStats.Models; + +namespace KeyStats.Services; + +public enum AchievementCategory +{ + TotalKeys, + DailyKeys, + TotalClicks, + Streak, + ActiveDays, + PeakKps, + MouseDistanceKm +} + +public sealed class AchievementDefinition +{ + public AchievementDefinition(string id, AchievementCategory category, double target) + { + Id = id; + Category = category; + Target = target; + } + + public string Id { get; } + public AchievementCategory Category { get; } + public double Target { get; } +} + +public sealed class AchievementProgress +{ + public AchievementProgress(AchievementDefinition definition, double current, DateTime? reachedOn) + { + Definition = definition; + Current = current; + ReachedOn = reachedOn; + } + + public AchievementDefinition Definition { get; } + /// 当前进度(累计类为累计值,单日 / 峰值 / 连续类为历史最高值)。 + public double Current { get; } + /// 首次达成的日期,未达成为 null。 + public DateTime? ReachedOn { get; } + public bool IsUnlocked => ReachedOn.HasValue; +} + +/// +/// 成就定义与判定。纯计算:按日期顺序扫描每日数据,得出每个成就的进度和首次达成日期。规则与 macOS 端保持一致。 +/// +public static class AchievementEvaluator +{ + public static readonly IReadOnlyList Definitions = new[] + { + new AchievementDefinition("keys_100k", AchievementCategory.TotalKeys, 100_000), + new AchievementDefinition("keys_1m", AchievementCategory.TotalKeys, 1_000_000), + new AchievementDefinition("keys_10m", AchievementCategory.TotalKeys, 10_000_000), + new AchievementDefinition("day_10k", AchievementCategory.DailyKeys, 10_000), + new AchievementDefinition("day_30k", AchievementCategory.DailyKeys, 30_000), + new AchievementDefinition("day_50k", AchievementCategory.DailyKeys, 50_000), + new AchievementDefinition("clicks_100k", AchievementCategory.TotalClicks, 100_000), + new AchievementDefinition("clicks_1m", AchievementCategory.TotalClicks, 1_000_000), + new AchievementDefinition("streak_7", AchievementCategory.Streak, 7), + new AchievementDefinition("streak_30", AchievementCategory.Streak, 30), + new AchievementDefinition("streak_100", AchievementCategory.Streak, 100), + new AchievementDefinition("active_100", AchievementCategory.ActiveDays, 100), + new AchievementDefinition("active_365", AchievementCategory.ActiveDays, 365), + new AchievementDefinition("kps_10", AchievementCategory.PeakKps, 10), + new AchievementDefinition("kps_15", AchievementCategory.PeakKps, 15), + new AchievementDefinition("marathon", AchievementCategory.MouseDistanceKm, 42.195), + }; + + public static bool IsActiveDay(DailyStats stats) => stats.KeyPresses > 0 || stats.TotalClicks > 0; + + public static IReadOnlyList Evaluate(IEnumerable days, double metersPerPixel) + { + long totalKeys = 0; + long totalClicks = 0; + double totalMeters = 0; + var maxDailyKeys = 0; + double maxKps = 0; + var activeDays = 0; + var currentStreak = 0; + var longestStreak = 0; + DateTime? previousActiveDate = null; + var reached = new Dictionary(StringComparer.Ordinal); + + foreach (var day in days.OrderBy(d => d.Date)) + { + var date = day.Date.Date; + totalKeys += Math.Max(0, day.KeyPresses); + totalClicks += Math.Max(0, day.TotalClicks); + if (!double.IsNaN(day.MouseDistance) && !double.IsInfinity(day.MouseDistance) && day.MouseDistance > 0) + { + totalMeters += day.MouseDistance * metersPerPixel; + } + maxDailyKeys = Math.Max(maxDailyKeys, day.KeyPresses); + maxKps = Math.Max(maxKps, day.PeakKPS); + + if (IsActiveDay(day)) + { + activeDays++; + currentStreak = previousActiveDate.HasValue && previousActiveDate.Value.AddDays(1) == date + ? currentStreak + 1 + : 1; + previousActiveDate = date; + longestStreak = Math.Max(longestStreak, currentStreak); + } + + foreach (var definition in Definitions) + { + if (!reached.ContainsKey(definition.Id) + && ValueFor(definition.Category, totalKeys, maxDailyKeys, totalClicks, longestStreak, activeDays, maxKps, totalMeters) >= definition.Target) + { + reached[definition.Id] = date; + } + } + } + + return Definitions + .Select(definition => new AchievementProgress( + definition, + ValueFor(definition.Category, totalKeys, maxDailyKeys, totalClicks, longestStreak, activeDays, maxKps, totalMeters), + reached.TryGetValue(definition.Id, out var reachedOn) ? reachedOn : (DateTime?)null)) + .ToList(); + } + + /// 从今天往回数连续活跃天数;今天还没活跃时从昨天算起。 + public static int CurrentStreak(IReadOnlyDictionary byDate, DateTime today) + { + bool Active(DateTime date) => byDate.TryGetValue(date.Date, out var stats) && IsActiveDay(stats); + + var date = Active(today) ? today.Date : today.Date.AddDays(-1); + var streak = 0; + while (Active(date)) + { + streak++; + date = date.AddDays(-1); + } + return streak; + } + + private static double ValueFor( + AchievementCategory category, + long totalKeys, + int maxDailyKeys, + long totalClicks, + int longestStreak, + int activeDays, + double maxKps, + double totalMeters) + { + return category switch + { + AchievementCategory.TotalKeys => totalKeys, + AchievementCategory.DailyKeys => maxDailyKeys, + AchievementCategory.TotalClicks => totalClicks, + AchievementCategory.Streak => longestStreak, + AchievementCategory.ActiveDays => activeDays, + AchievementCategory.PeakKps => maxKps, + AchievementCategory.MouseDistanceKm => totalMeters / 1000.0, + _ => 0 + }; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/AchievementService.cs b/KeyStats.Windows/KeyStats/Services/AchievementService.cs new file mode 100644 index 0000000..706dcf5 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/AchievementService.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows.Threading; +using KeyStats.Models; +using Strings = KeyStats.Properties.Strings; + +namespace KeyStats.Services; + +/// +/// 定时评估成就:新达成的写入设置,首次运行静默补记,之后新解锁的发通知。 +/// 跑在 UI 线程的 DispatcherTimer 上,和设置窗口读写 Settings 不会并发。 +/// +public sealed class AchievementService +{ + private static AchievementService? _instance; + public static AchievementService Instance => _instance ??= new AchievementService(); + + private const string DateFormat = "yyyy-MM-dd"; + private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(60) }; + private readonly DispatcherTimer _startupTimer = new() { Interval = TimeSpan.FromSeconds(3) }; + + public event Action? AchievementsChanged; + + private AchievementService() + { + _timer.Tick += (_, _) => Evaluate(); + _startupTimer.Tick += (_, _) => + { + _startupTimer.Stop(); + Evaluate(); + }; + } + + public void Start() + { + _startupTimer.Start(); + _timer.Start(); + } + + public void Stop() + { + _startupTimer.Stop(); + _timer.Stop(); + } + + public IReadOnlyList GetProgress() + { + var history = StatsManager.Instance.GetLocalHistorySnapshot().Values; + return AchievementEvaluator.Evaluate(history, StatsManager.Instance.GetMetersPerPixel()); + } + + /// 已记录的解锁日期;没有记录时退回到评估出的首次达成日期。 + public DateTime? GetUnlockDate(AchievementProgress progress) + { + var unlocked = StatsManager.Instance.Settings.UnlockedAchievements; + if (unlocked != null + && unlocked.TryGetValue(progress.Definition.Id, out var text) + && DateTime.TryParseExact(text, DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + return date; + } + return progress.ReachedOn; + } + + public void Evaluate() + { + try + { + var settings = StatsManager.Instance.Settings; + settings.UnlockedAchievements ??= new Dictionary(); + var newlyUnlocked = new List(); + + foreach (var progress in GetProgress().Where(p => p.IsUnlocked)) + { + if (settings.UnlockedAchievements.ContainsKey(progress.Definition.Id)) + { + continue; + } + settings.UnlockedAchievements[progress.Definition.Id] = + progress.ReachedOn!.Value.ToString(DateFormat, CultureInfo.InvariantCulture); + newlyUnlocked.Add(progress); + } + + var wasInitialized = settings.AchievementsInitialized; + if (newlyUnlocked.Count == 0 && wasInitialized) + { + return; + } + + settings.AchievementsInitialized = true; + StatsManager.Instance.SaveSettings(); + + if (wasInitialized && settings.AchievementNotificationsEnabled) + { + foreach (var progress in newlyUnlocked) + { + NotificationService.Instance.SendAchievementNotification( + GetName(progress.Definition.Id), + GetDescription(progress.Definition)); + App.CurrentApp?.TrackClick("achievement_unlocked", new Dictionary + { + ["achievement"] = progress.Definition.Id + }); + } + } + + if (newlyUnlocked.Count > 0) + { + AchievementsChanged?.Invoke(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Achievement evaluation failed: {ex.Message}"); + } + } + + public static string GetName(string id) => id switch + { + "keys_100k" => Strings.Achievement_keys_100k, + "keys_1m" => Strings.Achievement_keys_1m, + "keys_10m" => Strings.Achievement_keys_10m, + "day_10k" => Strings.Achievement_day_10k, + "day_30k" => Strings.Achievement_day_30k, + "day_50k" => Strings.Achievement_day_50k, + "clicks_100k" => Strings.Achievement_clicks_100k, + "clicks_1m" => Strings.Achievement_clicks_1m, + "streak_7" => Strings.Achievement_streak_7, + "streak_30" => Strings.Achievement_streak_30, + "streak_100" => Strings.Achievement_streak_100, + "active_100" => Strings.Achievement_active_100, + "active_365" => Strings.Achievement_active_365, + "kps_10" => Strings.Achievement_kps_10, + "kps_15" => Strings.Achievement_kps_15, + "marathon" => Strings.Achievement_marathon, + _ => id + }; + + public static string GetDescription(AchievementDefinition definition) + { + var culture = CultureInfo.CurrentCulture; + var target = definition.Category == AchievementCategory.MouseDistanceKm + ? definition.Target.ToString("0.###", culture) + : definition.Target.ToString("N0", culture); + var format = definition.Category switch + { + AchievementCategory.TotalKeys => Strings.Achievement_Desc_TotalKeys, + AchievementCategory.DailyKeys => Strings.Achievement_Desc_DailyKeys, + AchievementCategory.TotalClicks => Strings.Achievement_Desc_TotalClicks, + AchievementCategory.Streak => Strings.Achievement_Desc_Streak, + AchievementCategory.ActiveDays => Strings.Achievement_Desc_ActiveDays, + AchievementCategory.PeakKps => Strings.Achievement_Desc_PeakKps, + AchievementCategory.MouseDistanceKm => Strings.Achievement_Desc_MouseDistance, + _ => "{0}" + }; + return string.Format(culture, format, target); + } +} diff --git a/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs b/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs index e62bdca..e295a7c 100644 --- a/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs +++ b/KeyStats.Windows/KeyStats/Services/DisplayStatsAggregator.cs @@ -79,7 +79,9 @@ private static DailyStats Clone(DailyStats source, DateTime date) ScrollDistance = Math.Max(0, source.ScrollDistance), PeakKPS = Math.Max(0, source.PeakKPS), PeakCPS = Math.Max(0, source.PeakCPS), - AppStats = source.AppStats.ToDictionary(pair => pair.Key, pair => new AppStats(pair.Value)) + AppStats = source.AppStats.ToDictionary(pair => pair.Key, pair => new AppStats(pair.Value)), + HourlyKeyPresses = DailyStats.NormalizeHourly(source.HourlyKeyPresses), + HourlyClicks = DailyStats.NormalizeHourly(source.HourlyClicks) }; } diff --git a/KeyStats.Windows/KeyStats/Services/HourlyActivityAnalyzer.cs b/KeyStats.Windows/KeyStats/Services/HourlyActivityAnalyzer.cs new file mode 100644 index 0000000..42f7057 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/HourlyActivityAnalyzer.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeyStats.Models; + +namespace KeyStats.Services; + +public enum Chronotype +{ + NotEnoughData, + NightOwl, + EarlyBird, + AfternoonPerson, + EveningPerson +} + +public sealed class HourlyActivityResult +{ + /// 每小时日均值(只统计有小时数据的天)。 + public double[] HourlyAverages { get; set; } = new double[DailyStats.HoursPerDay]; + public int DayCount { get; set; } + public long Total { get; set; } + public int PeakHour { get; set; } = -1; + public double NightRatio { get; set; } + public Chronotype Chronotype { get; set; } = Chronotype.NotEnoughData; +} + +/// +/// 分时段活跃度:日均曲线、最活跃时段、作息类型。规则与 macOS 端保持一致。 +/// +public static class HourlyActivityAnalyzer +{ + public const int MinimumTotal = 200; + + private static readonly int[] NightHours = { 22, 23, 0, 1, 2, 3, 4 }; + + /// 这一天是否有小时数据:按键和点击的小时计数合计大于 0(上线前的旧数据都是 0)。 + public static bool HasHourlyData(DailyStats stats) + { + return DailyStats.NormalizeHourly(stats.HourlyKeyPresses).Any(v => v > 0) + || DailyStats.NormalizeHourly(stats.HourlyClicks).Any(v => v > 0); + } + + /// 范围内「有小时数据」的每一天在当前指标下的 24 小时计数,由调用方先用 HasHourlyData 筛选。 + public static HourlyActivityResult Analyze(IEnumerable days) + { + var sums = new long[DailyStats.HoursPerDay]; + var dayCount = 0; + foreach (var day in days) + { + var hours = DailyStats.NormalizeHourly(day); + dayCount++; + for (var hour = 0; hour < DailyStats.HoursPerDay; hour++) + { + sums[hour] += hours[hour]; + } + } + + var result = new HourlyActivityResult + { + DayCount = dayCount, + Total = sums.Sum() + }; + if (dayCount == 0) + { + return result; + } + + for (var hour = 0; hour < DailyStats.HoursPerDay; hour++) + { + result.HourlyAverages[hour] = (double)sums[hour] / dayCount; + } + + var peak = sums.Max(); + result.PeakHour = peak > 0 ? Array.IndexOf(sums, peak) : -1; + if (result.Total <= 0) + { + return result; + } + + var night = NightHours.Sum(hour => sums[hour]); + result.NightRatio = (double)night / result.Total; + if (result.Total < MinimumTotal) + { + return result; + } + + if (result.NightRatio >= 0.20) + { + result.Chronotype = Chronotype.NightOwl; + return result; + } + + var morning = SumRange(sums, 5, 10); + var afternoon = SumRange(sums, 11, 17); + var evening = SumRange(sums, 18, 21); + // 并列时取更早的时段 + result.Chronotype = morning >= afternoon && morning >= evening + ? Chronotype.EarlyBird + : afternoon >= evening + ? Chronotype.AfternoonPerson + : Chronotype.EveningPerson; + return result; + } + + private static long SumRange(long[] sums, int from, int to) + { + long total = 0; + for (var hour = from; hour <= to; hour++) + { + total += sums[hour]; + } + return total; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/KeyboardPersonaAnalyzer.cs b/KeyStats.Windows/KeyStats/Services/KeyboardPersonaAnalyzer.cs new file mode 100644 index 0000000..b7d79e4 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Services/KeyboardPersonaAnalyzer.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace KeyStats.Services; + +public enum KeyboardPersona +{ + ShortcutMaster, + CopyPasteEngineer, + Perfectionist, + ArrowKeyWalker, + MousePerson, + KeyboardPurist, + BalancedPlayer +} + +public sealed class KeyboardPersonaResult +{ + public bool HasEnoughData { get; set; } + public int TotalKeys { get; set; } + public int TotalClicks { get; set; } + public int ShortcutCount { get; set; } + public int RegretCount { get; set; } + public int CopyCount { get; set; } + public int PasteCount { get; set; } + public int UndoCount { get; set; } + public int ArrowCount { get; set; } + public double ShortcutRatio { get; set; } + public double RegretRatio { get; set; } + public double ArrowRatio { get; set; } + public double ClicksPerKey { get; set; } + public IReadOnlyList<(string Key, int Count)> TopKeys { get; set; } = Array.Empty<(string, int)>(); + public KeyboardPersona Persona { get; set; } = KeyboardPersona.BalancedPlayer; + public IReadOnlyList SecondaryPersonas { get; set; } = Array.Empty(); +} + +/// +/// 根据按键分布推断「键盘人格」。只做纯计算,规则与 macOS 端保持一致。 +/// +public static class KeyboardPersonaAnalyzer +{ + public const int MinimumKeyPresses = 200; + + private static readonly string[] ShortcutModifiers = { "Ctrl", "Alt", "Win" }; + private static readonly HashSet RegretKeys = new(StringComparer.Ordinal) { "Backspace", "Delete" }; + private static readonly HashSet ArrowKeys = new(StringComparer.Ordinal) { "Up", "Down", "Left", "Right" }; + + public static KeyboardPersonaResult Analyze(IReadOnlyDictionary keyCounts, int totalKeys, int totalClicks) + { + var result = new KeyboardPersonaResult + { + TotalKeys = Math.Max(0, totalKeys), + TotalClicks = Math.Max(0, totalClicks) + }; + + foreach (var pair in keyCounts) + { + if (pair.Value <= 0 || string.IsNullOrEmpty(pair.Key)) + { + continue; + } + + var parts = SplitKeyName(pair.Key); + var baseKey = parts[parts.Count - 1]; + var modifiers = parts.Take(parts.Count - 1).ToList(); + + if (modifiers.Any(m => ShortcutModifiers.Contains(m, StringComparer.Ordinal))) + { + result.ShortcutCount += pair.Value; + } + if (RegretKeys.Contains(baseKey)) + { + result.RegretCount += pair.Value; + } + if (ArrowKeys.Contains(baseKey)) + { + result.ArrowCount += pair.Value; + } + + switch (pair.Key) + { + case "Ctrl+C": + result.CopyCount += pair.Value; + break; + case "Ctrl+V": + result.PasteCount += pair.Value; + break; + case "Ctrl+Z": + result.UndoCount += pair.Value; + break; + } + } + + result.TopKeys = keyCounts + .Where(pair => pair.Value > 0 && !string.Equals(pair.Key, "Space", StringComparison.Ordinal)) + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Take(3) + .Select(pair => (pair.Key, pair.Value)) + .ToList(); + + if (result.TotalKeys > 0) + { + result.ShortcutRatio = (double)result.ShortcutCount / result.TotalKeys; + result.RegretRatio = (double)result.RegretCount / result.TotalKeys; + result.ArrowRatio = (double)result.ArrowCount / result.TotalKeys; + result.ClicksPerKey = (double)result.TotalClicks / result.TotalKeys; + } + + result.HasEnoughData = result.TotalKeys >= MinimumKeyPresses; + if (!result.HasEnoughData) + { + return result; + } + + var matched = new List(); + if (result.ShortcutRatio >= 0.12) matched.Add(KeyboardPersona.ShortcutMaster); + if (result.PasteCount >= 30 && result.PasteCount >= 2 * result.CopyCount) matched.Add(KeyboardPersona.CopyPasteEngineer); + if (result.RegretRatio >= 0.10) matched.Add(KeyboardPersona.Perfectionist); + if (result.ArrowRatio >= 0.08) matched.Add(KeyboardPersona.ArrowKeyWalker); + if (result.ClicksPerKey >= 1.0) matched.Add(KeyboardPersona.MousePerson); + if (result.ClicksPerKey <= 0.15) matched.Add(KeyboardPersona.KeyboardPurist); + + result.Persona = matched.Count > 0 ? matched[0] : KeyboardPersona.BalancedPlayer; + result.SecondaryPersonas = matched.Skip(1).ToList(); + return result; + } + + // "Ctrl+Shift+A" → [Ctrl, Shift, A];"Ctrl++" → [Ctrl, +];"+" → [+] + private static List SplitKeyName(string keyName) + { + var parts = new List(); + var raw = keyName; + var trailingPlus = raw.Length > 1 && raw.EndsWith("++", StringComparison.Ordinal); + if (trailingPlus) + { + raw = raw.Substring(0, raw.Length - 2); + } + + if (raw.Length <= 1) + { + parts.Add(raw); + } + else + { + parts.AddRange(raw.Split('+').Where(p => p.Length > 0)); + } + + if (trailingPlus) + { + parts.Add("+"); + } + if (parts.Count == 0) + { + parts.Add(keyName); + } + return parts; + } +} diff --git a/KeyStats.Windows/KeyStats/Services/NotificationService.cs b/KeyStats.Windows/KeyStats/Services/NotificationService.cs index e3625f8..1e8fdc6 100644 --- a/KeyStats.Windows/KeyStats/Services/NotificationService.cs +++ b/KeyStats.Windows/KeyStats/Services/NotificationService.cs @@ -38,6 +38,21 @@ public void SendThresholdNotification(Metric metric, int count) } } + public void SendAchievementNotification(string name, string description) + { + try + { + new ToastContentBuilder() + .AddText(string.Format(KeyStats.Properties.Strings.Notif_AchievementUnlockedFormat, name)) + .AddText(description) + .Show(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error showing notification: {ex.Message}"); + } + } + public void ClearNotifications() { try diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index 13446dd..12b8bfc 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -209,6 +209,7 @@ private void OnKeyPressed(string keyName, string appName, string displayName) EnsureCurrentDay(); CurrentStats.KeyPresses++; + CurrentStats.RecordHourlyKeyPress(DateTime.Now.Hour); if (!string.IsNullOrEmpty(keyName)) { if (!CurrentStats.KeyPressCounts.ContainsKey(keyName)) @@ -234,6 +235,7 @@ private void OnLeftClick(string appName, string displayName) EnsureCurrentDay(); CurrentStats.LeftClicks++; + CurrentStats.RecordHourlyClick(DateTime.Now.Hour); UpdateAppStats(appName, displayName, stats => stats.RecordLeftClick()); } @@ -251,6 +253,7 @@ private void OnRightClick(string appName, string displayName) EnsureCurrentDay(); CurrentStats.RightClicks++; + CurrentStats.RecordHourlyClick(DateTime.Now.Hour); UpdateAppStats(appName, displayName, stats => stats.RecordRightClick()); } @@ -268,6 +271,7 @@ private void OnMiddleClick(string appName, string displayName) EnsureCurrentDay(); CurrentStats.MiddleClicks++; + CurrentStats.RecordHourlyClick(DateTime.Now.Hour); UpdateAppStats(appName, displayName, stats => stats.RecordMiddleClick()); } @@ -285,6 +289,7 @@ private void OnSideBackClick(string appName, string displayName) EnsureCurrentDay(); CurrentStats.SideBackClicks++; + CurrentStats.RecordHourlyClick(DateTime.Now.Hour); UpdateAppStats(appName, displayName, stats => stats.RecordSideBackClick()); } @@ -302,6 +307,7 @@ private void OnSideForwardClick(string appName, string displayName) EnsureCurrentDay(); CurrentStats.SideForwardClicks++; + CurrentStats.RecordHourlyClick(DateTime.Now.Hour); UpdateAppStats(appName, displayName, stats => stats.RecordSideForwardClick()); } @@ -663,7 +669,9 @@ private void RecordCurrentStatsToHistory() PeakKPS = CurrentStats.PeakKPS, PeakCPS = CurrentStats.PeakCPS, KeyPressCounts = new Dictionary(CurrentStats.KeyPressCounts), - AppStats = CurrentStats.AppStats.ToDictionary(k => k.Key, v => new AppStats(v.Value)) + AppStats = CurrentStats.AppStats.ToDictionary(k => k.Key, v => new AppStats(v.Value)), + HourlyKeyPresses = DailyStats.NormalizeHourly(CurrentStats.HourlyKeyPresses), + HourlyClicks = DailyStats.NormalizeHourly(CurrentStats.HourlyClicks) }; History[key] = statsCopy; } @@ -955,7 +963,9 @@ private static DailyStats CloneDailyStats(DailyStats source, DateTime dateOverri PeakKPS = source.PeakKPS, PeakCPS = source.PeakCPS, KeyPressCounts = new Dictionary(source.KeyPressCounts), - AppStats = source.AppStats.ToDictionary(k => k.Key, v => new AppStats(v.Value)) + AppStats = source.AppStats.ToDictionary(k => k.Key, v => new AppStats(v.Value)), + HourlyKeyPresses = DailyStats.NormalizeHourly(source.HourlyKeyPresses), + HourlyClicks = DailyStats.NormalizeHourly(source.HourlyClicks) }; } @@ -1044,7 +1054,9 @@ private static DailyStats NormalizeDailyStats(DailyStats? source, DateTime fallb ScrollDistance = SanitizeDistance(source?.ScrollDistance ?? 0), PeakKPS = Math.Max(0, source?.PeakKPS ?? 0), PeakCPS = Math.Max(0, source?.PeakCPS ?? 0), - AppStats = appStats + AppStats = appStats, + HourlyKeyPresses = DailyStats.NormalizeHourly(source?.HourlyKeyPresses), + HourlyClicks = DailyStats.NormalizeHourly(source?.HourlyClicks) }; } @@ -1119,7 +1131,9 @@ private static DailyStats MergeDailyStats(DailyStats existing, DailyStats incomi PeakKPS = Math.Max(normalizedExisting.PeakKPS, normalizedIncoming.PeakKPS), PeakCPS = Math.Max(normalizedExisting.PeakCPS, normalizedIncoming.PeakCPS), KeyPressCounts = keyPressCounts, - AppStats = appStats + AppStats = appStats, + HourlyKeyPresses = DailyStats.SumHourly(normalizedExisting.HourlyKeyPresses, normalizedIncoming.HourlyKeyPresses), + HourlyClicks = DailyStats.SumHourly(normalizedExisting.HourlyClicks, normalizedIncoming.HourlyClicks) }; } @@ -2033,7 +2047,7 @@ public void UpdateMouseDistanceUnit(string unit) NotifyStatsUpdate(); } - private double GetMetersPerPixel() + public double GetMetersPerPixel() { var metersPerPixel = Settings.MouseMetersPerPixel; if (double.IsNaN(metersPerPixel) || double.IsInfinity(metersPerPixel) || metersPerPixel <= 0) diff --git a/KeyStats.Windows/KeyStats/Views/Controls/ContributionWallControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/ContributionWallControl.cs new file mode 100644 index 0000000..950623f --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/Controls/ContributionWallControl.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using KeyStats.Helpers; + +namespace KeyStats.Views.Controls; + +public sealed class ContributionDay +{ + public ContributionDay(DateTime date, int value, bool isInRange) + { + Date = date; + Value = value; + IsInRange = isInRange; + } + + public DateTime Date { get; } + public int Value { get; } + public bool IsInRange { get; } +} + +/// +/// GitHub 风格的年度贡献墙:列为周、行为周一到周日,颜色跟随热力图配色。 +/// +public class ContributionWallControl : FrameworkElement +{ + private const double MaxCellSize = 15; + private const double MinCellSize = 6; + private const double CellGap = 3; + private const double LeftLabelWidth = 30; + private const double TopLabelHeight = 18; + private const double LegendHeight = 26; + + private IReadOnlyList _days = Array.Empty(); + private int[] _quartiles = Array.Empty(); + private readonly ToolTip _toolTip; + + public ContributionWallControl() + { + SnapsToDevicePixels = true; + UseLayoutRounding = true; + _toolTip = new ToolTip + { + PlacementTarget = this, + Placement = PlacementMode.Relative + }; + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + /// 悬停提示文案,由窗口按当前指标提供。 + public Func? TooltipFormatter { get; set; } + + public string LessText { get; set; } = string.Empty; + + public string MoreText { get; set; } = string.Empty; + + /// days 必须从某个周一开始、按天连续。 + public void Apply(IReadOnlyList days) + { + _days = days; + // GitHub 式四分位分档:有数据的日子按 25/50/75 百分位分成 4 档,和图例一一对应 + var sorted = days.Where(d => d.IsInRange && d.Value > 0).Select(d => d.Value).OrderBy(v => v).ToList(); + _quartiles = sorted.Count == 0 + ? Array.Empty() + : new[] { 0.25, 0.5, 0.75 }.Select(p => sorted[(int)((sorted.Count - 1) * p)]).ToArray(); + InvalidateMeasure(); + InvalidateVisual(); + } + + private int ColumnCount => (_days.Count + 6) / 7; + + private double CellSizeFor(double width) + { + var columns = Math.Max(1, ColumnCount); + var available = width - LeftLabelWidth - CellGap * (columns - 1); + return Math.Max(MinCellSize, Math.Min(MaxCellSize, available / columns)); + } + + protected override Size MeasureOverride(Size availableSize) + { + var width = double.IsInfinity(availableSize.Width) ? 900 : availableSize.Width; + var cell = CellSizeFor(width); + var height = TopLabelHeight + cell * 7 + CellGap * 6 + LegendHeight; + return new Size(width, height); + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + if (_days.Count == 0 || ActualWidth <= 0) + { + return; + } + + var isDarkMode = ThemeManager.Instance.IsDarkTheme; + var cell = CellSizeFor(ActualWidth); + var radius = Math.Max(2, cell * 0.2); + var labelBrush = Freeze(new SolidColorBrush(HeatmapPalette.ResolveColor("TextSecondaryBrush", Colors.Gray))); + var borderPen = new Pen(Freeze(new SolidColorBrush(isDarkMode + ? Color.FromArgb(28, 255, 255, 255) + : Color.FromArgb(20, 0, 0, 0))), 0.6); + borderPen.Freeze(); + var culture = CultureInfo.CurrentUICulture; + + // 左侧星期标签:只标周一、周三、周五 + foreach (var row in new[] { 0, 2, 4 }) + { + var dayOfWeek = (DayOfWeek)((row + 1) % 7); + var text = CreateText(culture.DateTimeFormat.GetAbbreviatedDayName(dayOfWeek), 10, labelBrush); + var y = TopLabelHeight + row * (cell + CellGap) + (cell - text.Height) / 2; + drawingContext.DrawText(text, new Point(0, y)); + } + + var lastMonthLabelRight = double.MinValue; + for (var index = 0; index < _days.Count; index++) + { + var day = _days[index]; + var column = index / 7; + var row = index % 7; + var x = LeftLabelWidth + column * (cell + CellGap); + var y = TopLabelHeight + row * (cell + CellGap); + + // 顶部月份标签:标在每月第一天所在的列 + if ((day.IsInRange && day.Date.Day == 1) || (index == 0 && day.Date.Day <= 7)) + { + var monthText = CreateText(culture.DateTimeFormat.GetAbbreviatedMonthName(day.Date.Month), 10, labelBrush); + var labelX = LeftLabelWidth + column * (cell + CellGap); + if (labelX > lastMonthLabelRight + 4) + { + drawingContext.DrawText(monthText, new Point(labelX, 0)); + lastMonthLabelRight = labelX + monthText.Width; + } + } + + if (!day.IsInRange) + { + continue; + } + + var fill = Freeze(new SolidColorBrush(ColorFor(day.Value, isDarkMode))); + drawingContext.DrawRoundedRectangle(fill, borderPen, new Rect(x, y, cell, cell), radius, radius); + } + + DrawLegend(drawingContext, cell, radius, isDarkMode, labelBrush, borderPen); + } + + private void DrawLegend(DrawingContext drawingContext, double cell, double radius, bool isDarkMode, Brush labelBrush, Pen borderPen) + { + var legendCell = Math.Min(cell, 12); + var gridBottom = TopLabelHeight + cell * 7 + CellGap * 6; + var top = gridBottom + (LegendHeight - legendCell) / 2 + 2; + var gridRight = LeftLabelWidth + ColumnCount * (cell + CellGap) - CellGap; + + var moreText = CreateText(MoreText, 10, labelBrush); + var lessText = CreateText(LessText, 10, labelBrush); + var x = gridRight - moreText.Width; + drawingContext.DrawText(moreText, new Point(x, top + (legendCell - moreText.Height) / 2)); + x -= 6; + + var levels = new[] { 1.0, 0.75, 0.5, 0.25 }; + foreach (var level in levels) + { + x -= legendCell; + var brush = Freeze(new SolidColorBrush(HeatmapPalette.ColorFor(level, isDarkMode))); + drawingContext.DrawRoundedRectangle(brush, borderPen, new Rect(x, top, legendCell, legendCell), radius, radius); + x -= CellGap; + } + x -= legendCell; + var neutral = Freeze(new SolidColorBrush(HeatmapPalette.NeutralColor(isDarkMode))); + drawingContext.DrawRoundedRectangle(neutral, borderPen, new Rect(x, top, legendCell, legendCell), radius, radius); + x -= 6 + lessText.Width; + drawingContext.DrawText(lessText, new Point(x, top + (legendCell - lessText.Height) / 2)); + } + + private Color ColorFor(int value, bool isDarkMode) + { + if (value <= 0 || _quartiles.Length == 0) + { + return HeatmapPalette.NeutralColor(isDarkMode); + } + + var intensity = value <= _quartiles[0] ? 0.25 + : value <= _quartiles[1] ? 0.5 + : value <= _quartiles[2] ? 0.75 + : 1.0; + return HeatmapPalette.ColorFor(intensity, isDarkMode); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + var day = HitTestDay(e.GetPosition(this)); + if (day == null || TooltipFormatter == null) + { + _toolTip.IsOpen = false; + return; + } + + var position = e.GetPosition(this); + _toolTip.Content = TooltipFormatter(day); + _toolTip.HorizontalOffset = position.X + 12; + _toolTip.VerticalOffset = position.Y + 16; + _toolTip.IsOpen = true; + } + + protected override void OnMouseLeave(MouseEventArgs e) + { + base.OnMouseLeave(e); + _toolTip.IsOpen = false; + } + + private ContributionDay? HitTestDay(Point point) + { + if (_days.Count == 0) + { + return null; + } + + var cell = CellSizeFor(ActualWidth); + var step = cell + CellGap; + var localX = point.X - LeftLabelWidth; + var localY = point.Y - TopLabelHeight; + if (localX < 0 || localY < 0) + { + return null; + } + + var column = (int)(localX / step); + var row = (int)(localY / step); + if (row > 6 || localX - column * step > cell || localY - row * step > cell) + { + return null; + } + + var index = column * 7 + row; + if (index >= _days.Count || !_days[index].IsInRange) + { + return null; + } + return _days[index]; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + ThemeManager.Instance.ThemeChanged += OnPaletteChanged; + HeatmapPalette.SchemeChanged += OnPaletteChanged; + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + ThemeManager.Instance.ThemeChanged -= OnPaletteChanged; + HeatmapPalette.SchemeChanged -= OnPaletteChanged; + _toolTip.IsOpen = false; + } + + private void OnPaletteChanged() + { + Dispatcher.BeginInvoke(new Action(InvalidateVisual)); + } + + private FormattedText CreateText(string value, double fontSize, Brush brush) + { + return new FormattedText( + value, + CultureInfo.CurrentUICulture, + FlowDirection.LeftToRight, + new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), + fontSize, + brush, + VisualTreeHelper.GetDpi(this).PixelsPerDip); + } + + private static T Freeze(T freezable) where T : Freezable + { + freezable.Freeze(); + return freezable; + } +} diff --git a/KeyStats.Windows/KeyStats/Views/Controls/HourlyActivityChartControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/HourlyActivityChartControl.cs new file mode 100644 index 0000000..eeb6662 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/Controls/HourlyActivityChartControl.cs @@ -0,0 +1,164 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using KeyStats.Helpers; + +namespace KeyStats.Views.Controls; + +/// +/// 24 小时活跃度柱状图:柱色按相对高度跟随热力图配色,最高的柱顶标数值。 +/// +public class HourlyActivityChartControl : FrameworkElement +{ + private const int Hours = 24; + private const double ChartHeight = 200; + private const double TopLabelSpace = 18; + private const double AxisLabelSpace = 20; + private const double BarGapRatio = 0.28; + + private double[] _values = new double[Hours]; + private int _peakHour = -1; + private readonly ToolTip _toolTip; + + public HourlyActivityChartControl() + { + SnapsToDevicePixels = true; + UseLayoutRounding = true; + _toolTip = new ToolTip { PlacementTarget = this, Placement = PlacementMode.Relative }; + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + /// 悬停提示文案:参数为小时与该小时的值。 + public Func? TooltipFormatter { get; set; } + + public void Apply(double[] values, int peakHour) + { + _values = values.Length == Hours ? values.ToArray() : new double[Hours]; + _peakHour = peakHour; + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var width = double.IsInfinity(availableSize.Width) ? 800 : availableSize.Width; + return new Size(width, ChartHeight + TopLabelSpace + AxisLabelSpace); + } + + private double SlotWidth => ActualWidth / Hours; + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + if (ActualWidth <= 0) + { + return; + } + + var isDarkMode = ThemeManager.Instance.IsDarkTheme; + var labelBrush = Freeze(new SolidColorBrush(HeatmapPalette.ResolveColor("TextSecondaryBrush", Colors.Gray))); + var valueBrush = Freeze(new SolidColorBrush(HeatmapPalette.ResolveColor("TextPrimaryBrush", Colors.Black))); + var gridPen = new Pen(Freeze(new SolidColorBrush(isDarkMode + ? Color.FromArgb(30, 255, 255, 255) + : Color.FromArgb(22, 0, 0, 0))), 1); + gridPen.Freeze(); + + var baseline = TopLabelSpace + ChartHeight; + drawingContext.DrawLine(gridPen, new Point(0, baseline), new Point(ActualWidth, baseline)); + + var max = _values.Max(); + var slot = SlotWidth; + var barWidth = slot * (1 - BarGapRatio); + var radius = Math.Min(4, barWidth / 3); + + for (var hour = 0; hour < Hours; hour++) + { + var value = _values[hour]; + var x = hour * slot + (slot - barWidth) / 2; + var ratio = max > 0 ? value / max : 0; + var height = value > 0 ? Math.Max(3, ratio * ChartHeight) : 2; + var color = value > 0 + ? HeatmapPalette.ColorFor(Math.Max(0.15, ratio), isDarkMode) + : HeatmapPalette.NeutralColor(isDarkMode); + var rect = new Rect(x, baseline - height, barWidth, height); + drawingContext.DrawRoundedRectangle(Freeze(new SolidColorBrush(color)), null, rect, radius, radius); + + if (hour % 3 == 0) + { + var label = CreateText(hour.ToString(CultureInfo.InvariantCulture), 10, labelBrush, FontWeights.Normal); + drawingContext.DrawText(label, new Point(hour * slot + (slot - label.Width) / 2, baseline + 4)); + } + + if (hour == _peakHour && value > 0) + { + var peakText = CreateText(Math.Round(value).ToString("N0", CultureInfo.CurrentCulture), 11, valueBrush, FontWeights.SemiBold); + var textX = Math.Max(0, Math.Min(ActualWidth - peakText.Width, x + (barWidth - peakText.Width) / 2)); + drawingContext.DrawText(peakText, new Point(textX, rect.Top - peakText.Height - 2)); + } + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + var position = e.GetPosition(this); + var hour = (int)(position.X / Math.Max(1, SlotWidth)); + if (TooltipFormatter == null || hour < 0 || hour >= Hours || position.Y > TopLabelSpace + ChartHeight) + { + _toolTip.IsOpen = false; + return; + } + + _toolTip.Content = TooltipFormatter(hour, _values[hour]); + _toolTip.HorizontalOffset = position.X + 12; + _toolTip.VerticalOffset = position.Y + 16; + _toolTip.IsOpen = true; + } + + protected override void OnMouseLeave(MouseEventArgs e) + { + base.OnMouseLeave(e); + _toolTip.IsOpen = false; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + ThemeManager.Instance.ThemeChanged += OnPaletteChanged; + HeatmapPalette.SchemeChanged += OnPaletteChanged; + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + ThemeManager.Instance.ThemeChanged -= OnPaletteChanged; + HeatmapPalette.SchemeChanged -= OnPaletteChanged; + _toolTip.IsOpen = false; + } + + private void OnPaletteChanged() + { + Dispatcher.BeginInvoke(new Action(InvalidateVisual)); + } + + private FormattedText CreateText(string value, double fontSize, Brush brush, FontWeight weight) + { + return new FormattedText( + value, + CultureInfo.CurrentUICulture, + FlowDirection.LeftToRight, + new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal), + fontSize, + brush, + VisualTreeHelper.GetDpi(this).PixelsPerDip); + } + + private static T Freeze(T freezable) where T : Freezable + { + freezable.Freeze(); + return freezable; + } +} diff --git a/KeyStats.Windows/KeyStats/Views/NotificationSettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/NotificationSettingsWindow.xaml index e1a2ee6..2cd6103 100644 --- a/KeyStats.Windows/KeyStats/Views/NotificationSettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/NotificationSettingsWindow.xaml @@ -63,6 +63,18 @@ + + + + + + + + + { + ["enabled"] = enabled + }); + } + private void UpdateFieldsEnabled() { var enabled = EnableCheckBox.IsChecked == true; diff --git a/KeyStats.Windows/KeyStats/Views/StatsPopupWindow.xaml b/KeyStats.Windows/KeyStats/Views/StatsPopupWindow.xaml index aefbbe7..10f2c1e 100644 --- a/KeyStats.Windows/KeyStats/Views/StatsPopupWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/StatsPopupWindow.xaml @@ -40,6 +40,16 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs new file mode 100644 index 0000000..afd7c9a --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs @@ -0,0 +1,636 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using KeyStats.Helpers; +using KeyStats.Models; +using KeyStats.Services; +using KeyStats.Views.Controls; +using Strings = KeyStats.Properties.Strings; + +namespace KeyStats.Views; + +public partial class YearReviewWindow : Window +{ + private const int PastYearRangeId = 0; + + private readonly bool _isInitialized; + private Dictionary _history = new(); + private int _personaRangeIndex = 2; + private int _hourlyRangeIndex = 2; + private RangeOption _selectedRange = new(PastYearRangeId, Strings.YearReview_RangePast12Months); + private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(5) }; + + public YearReviewWindow() + { + InitializeComponent(); + _refreshTimer.Tick += OnRefreshTimerTick; + ContributionWall.LessText = Strings.Settings_HeatmapPreviewLess; + ContributionWall.MoreText = Strings.Settings_HeatmapPreviewMore; + ContributionWall.TooltipFormatter = FormatTooltip; + + LoadHistory(); + LoadRangeOptions(); + PersonaMonthButton.IsChecked = true; + HourlyMonthButton.IsChecked = true; + HourlyChart.TooltipFormatter = FormatHourlyTooltip; + _isInitialized = true; + RefreshWall(); + RefreshPersona(); + RefreshHourly(); + RefreshAchievements(); + AchievementService.Instance.AchievementsChanged += OnAchievementsChanged; + + Loaded += OnLoaded; + Closed += OnClosed; + ThemeManager.Instance.ThemeChanged += OnThemeChanged; + StatsManager.Instance.StatsUpdateRequested += OnStatsUpdateRequested; + } + + private bool IsClicksMetric => MetricClicksButton.IsChecked == true; + + private void OnLoaded(object sender, RoutedEventArgs e) + { + ApplyWindowBackdrop(); + App.CurrentApp?.TrackPageView("year_review"); + } + + private void OnClosed(object? sender, EventArgs e) + { + ThemeManager.Instance.ThemeChanged -= OnThemeChanged; + StatsManager.Instance.StatsUpdateRequested -= OnStatsUpdateRequested; + AchievementService.Instance.AchievementsChanged -= OnAchievementsChanged; + _refreshTimer.Stop(); + _refreshTimer.Tick -= OnRefreshTimerTick; + } + + private void OnThemeChanged() + { + Dispatcher.BeginInvoke(new Action(ApplyWindowBackdrop)); + } + + // 打字时统计更新很频繁,合并成最多每 5 秒刷新一次,避免反复复制整份历史 + private void OnStatsUpdateRequested() + { + Dispatcher.BeginInvoke(new Action(() => + { + if (_refreshTimer.IsEnabled) + { + return; + } + _refreshTimer.Start(); + })); + } + + private void OnRefreshTimerTick(object? sender, EventArgs e) + { + _refreshTimer.Stop(); + LoadHistory(); + RefreshWall(); + RefreshPersona(); + RefreshHourly(); + RefreshAchievements(); + } + + private void ApplyWindowBackdrop() + { + WindowBackdropHelper.Apply(this, NativeInterop.DwmSystemBackdropType.TransientWindow); + } + + private void LoadHistory() + { + _history = new Dictionary(); + foreach (var pair in StatsManager.Instance.GetLocalHistorySnapshot()) + { + if (DateTime.TryParseExact(pair.Key, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + _history[date.Date] = pair.Value; + } + } + } + + private void LoadRangeOptions() + { + var today = DateTime.Today; + var firstYear = _history.Keys.Where(HasActivity).Select(d => d.Year).DefaultIfEmpty(today.Year).Min(); + var options = new List { new(PastYearRangeId, Strings.YearReview_RangePast12Months) }; + for (var year = today.Year; year >= firstYear; year--) + { + options.Add(new RangeOption(year, string.Format(Strings.YearReview_RangeYearFormat, year))); + } + WallRangePanel.Children.Clear(); + foreach (var option in options) + { + var button = new RadioButton + { + Content = option.Label, + Tag = option, + GroupName = "YearReviewWallRange", + Style = (Style)FindResource("YearReviewSegmentButton"), + IsChecked = option.Id == _selectedRange.Id + }; + button.Checked += WallRange_Checked; + WallRangePanel.Children.Add(button); + } + } + + private bool HasActivity(DateTime date) + { + return _history.TryGetValue(date, out var stats) && (stats.KeyPresses > 0 || stats.TotalClicks > 0); + } + + private int ValueFor(DateTime date) + { + if (!_history.TryGetValue(date, out var stats)) + { + return 0; + } + return IsClicksMetric ? stats.TotalClicks : stats.KeyPresses; + } + + private void RefreshWall() + { + if (!_isInitialized) + { + return; + } + + var range = _selectedRange; + var today = DateTime.Today; + DateTime rangeStart; + DateTime rangeEnd; + if (range.Id == PastYearRangeId) + { + rangeEnd = today; + rangeStart = StartOfWeek(today).AddDays(-52 * 7); + } + else + { + rangeStart = new DateTime(range.Id, 1, 1); + rangeEnd = new DateTime(range.Id, 12, 31) < today ? new DateTime(range.Id, 12, 31) : today; + } + + var gridStart = StartOfWeek(rangeStart); + var gridEnd = range.Id == PastYearRangeId + ? StartOfWeek(today).AddDays(6) + : StartOfWeek(new DateTime(range.Id, 12, 31)).AddDays(6); + + var days = new List(); + for (var date = gridStart; date <= gridEnd; date = date.AddDays(1)) + { + var inRange = date >= rangeStart && date <= rangeEnd; + days.Add(new ContributionDay(date, inRange ? ValueFor(date) : 0, inRange)); + } + ContributionWall.Apply(days); + + var inRangeDays = days.Where(d => d.IsInRange).ToList(); + TotalValueText.Text = inRangeDays.Sum(d => (long)d.Value).ToString("N0", CultureInfo.CurrentCulture); + ActiveDaysValueText.Text = FormatDays(inRangeDays.Count(d => d.Value > 0)); + LongestStreakValueText.Text = FormatDays(LongestStreak(inRangeDays)); + CurrentStreakValueText.Text = rangeEnd == today ? FormatDays(CurrentStreak(today)) : "—"; + + var best = inRangeDays.Where(d => d.Value > 0).OrderByDescending(d => d.Value).ThenBy(d => d.Date).FirstOrDefault(); + BestDayValueText.Text = best == null ? "—" : best.Value.ToString("N0", CultureInfo.CurrentCulture); + BestDayDetailText.Text = best == null + ? string.Empty + : best.Date.ToString(Strings.YearReview_ShortDateFormat, CultureInfo.CurrentUICulture); + } + + private static int LongestStreak(IEnumerable days) + { + var longest = 0; + var current = 0; + foreach (var day in days) + { + current = day.Value > 0 ? current + 1 : 0; + longest = Math.Max(longest, current); + } + return longest; + } + + // 从今天往回数连续有记录的天数;今天还没开始用时从昨天算起 + private int CurrentStreak(DateTime today) + { + var date = ValueFor(today) > 0 ? today : today.AddDays(-1); + var streak = 0; + while (ValueFor(date) > 0) + { + streak++; + date = date.AddDays(-1); + } + return streak; + } + + private static DateTime StartOfWeek(DateTime date) + { + var offset = ((int)date.DayOfWeek + 6) % 7; + return date.Date.AddDays(-offset); + } + + private static string FormatDays(int days) + { + return string.Format(CultureInfo.CurrentCulture, Strings.YearReview_DaysFormat, days); + } + + private string FormatTooltip(ContributionDay day) + { + var dateText = day.Date.ToString(Strings.YearReview_TooltipDateFormat, CultureInfo.CurrentUICulture); + var format = IsClicksMetric ? Strings.YearReview_TooltipClicksFormat : Strings.YearReview_TooltipKeysFormat; + return string.Format(CultureInfo.CurrentCulture, format, dateText, day.Value); + } + + private void RefreshPersona() + { + if (!_isInitialized) + { + return; + } + + var today = DateTime.Today; + var start = _personaRangeIndex switch + { + 0 => today, + 1 => today.AddDays(-6), + 2 => today.AddDays(-29), + _ => DateTime.MinValue + }; + + var keyCounts = new Dictionary(StringComparer.Ordinal); + var totalKeys = 0; + var totalClicks = 0; + foreach (var pair in _history.Where(p => p.Key >= start && p.Key <= today)) + { + totalKeys += pair.Value.KeyPresses; + totalClicks += pair.Value.TotalClicks; + foreach (var key in pair.Value.KeyPressCounts) + { + keyCounts[key.Key] = keyCounts.TryGetValue(key.Key, out var existing) ? existing + key.Value : key.Value; + } + } + + var result = KeyboardPersonaAnalyzer.Analyze(keyCounts, totalKeys, totalClicks); + if (result.HasEnoughData) + { + PersonaTitleText.Text = PersonaName(result.Persona); + PersonaDescText.Text = PersonaDescription(result.Persona); + PersonaTagsList.ItemsSource = result.SecondaryPersonas.Select(PersonaName).ToList(); + } + else + { + PersonaTitleText.Text = "—"; + PersonaDescText.Text = Strings.YearReview_PersonaNotEnough; + PersonaTagsList.ItemsSource = null; + } + + var culture = CultureInfo.CurrentCulture; + PersonaTotalsText.Text = string.Format(culture, Strings.YearReview_PersonaTotalsFormat, totalKeys, totalClicks); + PersonaMetricsList.ItemsSource = new List + { + new(Strings.YearReview_MetricShortcutRatio, Percent(result.ShortcutRatio), + string.Format(culture, Strings.YearReview_MetricShortcutDetailFormat, result.ShortcutCount)), + new(Strings.YearReview_MetricRegret, Percent(result.RegretRatio), + string.Format(culture, Strings.YearReview_MetricRegretDetailFormat, result.RegretCount)), + new(Strings.YearReview_MetricCopyPaste, + string.Format(culture, Strings.YearReview_MetricCopyPasteValueFormat, result.CopyCount, result.PasteCount), + "Ctrl+C / Ctrl+V"), + new(Strings.YearReview_MetricUndo, result.UndoCount.ToString("N0", culture), "Ctrl+Z"), + new(Strings.YearReview_MetricArrow, Percent(result.ArrowRatio), + string.Format(culture, Strings.YearReview_MetricArrowDetailFormat, result.ArrowCount)), + new(Strings.YearReview_MetricClickRatio, + string.Format(culture, Strings.YearReview_MetricClickRatioValueFormat, Math.Round(result.ClicksPerKey * 100)), + string.Empty), + new(Strings.YearReview_MetricTopKeys, + result.TopKeys.Count == 0 ? "—" : string.Join(" · ", result.TopKeys.Select(k => k.Key)), + Strings.YearReview_MetricTopKeysDetail), + }; + } + + private static string Percent(double ratio) + { + return (ratio * 100).ToString("0.0", CultureInfo.CurrentCulture) + "%"; + } + + private static string PersonaName(KeyboardPersona persona) => persona switch + { + KeyboardPersona.ShortcutMaster => Strings.YearReview_Persona_ShortcutMaster, + KeyboardPersona.CopyPasteEngineer => Strings.YearReview_Persona_CopyPasteEngineer, + KeyboardPersona.Perfectionist => Strings.YearReview_Persona_Perfectionist, + KeyboardPersona.ArrowKeyWalker => Strings.YearReview_Persona_ArrowKeyWalker, + KeyboardPersona.MousePerson => Strings.YearReview_Persona_MousePerson, + KeyboardPersona.KeyboardPurist => Strings.YearReview_Persona_KeyboardPurist, + _ => Strings.YearReview_Persona_BalancedPlayer + }; + + private static string PersonaDescription(KeyboardPersona persona) => persona switch + { + KeyboardPersona.ShortcutMaster => Strings.YearReview_Persona_ShortcutMasterDesc, + KeyboardPersona.CopyPasteEngineer => Strings.YearReview_Persona_CopyPasteEngineerDesc, + KeyboardPersona.Perfectionist => Strings.YearReview_Persona_PerfectionistDesc, + KeyboardPersona.ArrowKeyWalker => Strings.YearReview_Persona_ArrowKeyWalkerDesc, + KeyboardPersona.MousePerson => Strings.YearReview_Persona_MousePersonDesc, + KeyboardPersona.KeyboardPurist => Strings.YearReview_Persona_KeyboardPuristDesc, + _ => Strings.YearReview_Persona_BalancedPlayerDesc + }; + + private void Tab_Checked(object sender, RoutedEventArgs e) + { + if (!_isInitialized) + { + return; + } + + var tab = WallTabButton.IsChecked == true ? "wall" + : PersonaTabButton.IsChecked == true ? "persona" + : HourlyTabButton.IsChecked == true ? "hourly" + : "achievements"; + WallPanel.Visibility = tab == "wall" ? Visibility.Visible : Visibility.Collapsed; + PersonaPanel.Visibility = tab == "persona" ? Visibility.Visible : Visibility.Collapsed; + HourlyPanel.Visibility = tab == "hourly" ? Visibility.Visible : Visibility.Collapsed; + AchievementsPanel.Visibility = tab == "achievements" ? Visibility.Visible : Visibility.Collapsed; + App.CurrentApp?.TrackClick("year_review_tab", new Dictionary + { + ["tab"] = tab + }); + } + + private void WallRange_Checked(object sender, RoutedEventArgs e) + { + if (sender is not RadioButton { Tag: RangeOption range }) + { + return; + } + + _selectedRange = range; + if (!_isInitialized) + { + return; + } + + RefreshWall(); + App.CurrentApp?.TrackClick("year_review_range", new Dictionary + { + ["range"] = range.Id == PastYearRangeId ? "past_12_months" : range.Id.ToString(CultureInfo.InvariantCulture) + }); + } + + private void Metric_Checked(object sender, RoutedEventArgs e) + { + if (!_isInitialized) + { + return; + } + + RefreshWall(); + App.CurrentApp?.TrackClick("year_review_metric", new Dictionary + { + ["metric"] = IsClicksMetric ? "clicks" : "keys" + }); + } + + private void PersonaRange_Checked(object sender, RoutedEventArgs e) + { + if (sender is not RadioButton button || !int.TryParse(button.Tag as string, out var index)) + { + return; + } + + _personaRangeIndex = index; + if (!_isInitialized) + { + return; + } + + RefreshPersona(); + App.CurrentApp?.TrackClick("year_review_persona_range", new Dictionary + { + ["range"] = index switch { 0 => "today", 1 => "week", 2 => "month", _ => "all" } + }); + } + + private void OnAchievementsChanged() + { + Dispatcher.BeginInvoke(new Action(RefreshAchievements)); + } + + private bool IsHourlyClicksMetric => HourlyClicksButton.IsChecked == true; + + private void RefreshHourly() + { + if (!_isInitialized) + { + return; + } + + var today = DateTime.Today; + var start = StartOfPeriod(_hourlyRangeIndex, today); + var days = _history + .Where(pair => pair.Key >= start && pair.Key <= today && HourlyActivityAnalyzer.HasHourlyData(pair.Value)) + .Select(pair => IsHourlyClicksMetric ? pair.Value.HourlyClicks : pair.Value.HourlyKeyPresses) + .ToList(); + var result = HourlyActivityAnalyzer.Analyze(days); + + var hasData = result.DayCount > 0; + HourlyChartPanel.Visibility = hasData ? Visibility.Visible : Visibility.Collapsed; + HourlyEmptyText.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; + HourlyChart.Apply(result.HourlyAverages, result.PeakHour); + + HourlyPeakText.Text = result.PeakHour >= 0 ? FormatHourRange(result.PeakHour) : "—"; + NightRatioText.Text = result.Total > 0 ? Percent(result.NightRatio) : "—"; + HourlyDayCountText.Text = FormatDays(result.DayCount); + ChronotypeText.Text = ChronotypeName(result.Chronotype); + ChronotypeDescText.Text = ChronotypeDescription(result.Chronotype); + } + + private static DateTime StartOfPeriod(int rangeIndex, DateTime today) => rangeIndex switch + { + 0 => today, + 1 => today.AddDays(-6), + 2 => today.AddDays(-29), + _ => DateTime.MinValue + }; + + private static string FormatHourRange(int hour) + { + return string.Format(CultureInfo.InvariantCulture, Strings.Hourly_HourRangeFormat, hour, (hour + 1) % 24); + } + + private string FormatHourlyTooltip(int hour, double value) + { + var format = IsHourlyClicksMetric ? Strings.Hourly_TooltipClicksFormat : Strings.Hourly_TooltipKeysFormat; + return string.Format(CultureInfo.CurrentCulture, format, FormatHourRange(hour), Math.Round(value)); + } + + private static string ChronotypeName(Chronotype chronotype) => chronotype switch + { + Chronotype.NightOwl => Strings.Chronotype_NightOwl, + Chronotype.EarlyBird => Strings.Chronotype_EarlyBird, + Chronotype.AfternoonPerson => Strings.Chronotype_AfternoonPerson, + Chronotype.EveningPerson => Strings.Chronotype_EveningPerson, + _ => "—" + }; + + private static string ChronotypeDescription(Chronotype chronotype) => chronotype switch + { + Chronotype.NightOwl => Strings.Chronotype_NightOwlDesc, + Chronotype.EarlyBird => Strings.Chronotype_EarlyBirdDesc, + Chronotype.AfternoonPerson => Strings.Chronotype_AfternoonPersonDesc, + Chronotype.EveningPerson => Strings.Chronotype_EveningPersonDesc, + _ => Strings.Chronotype_NotEnough + }; + + private void HourlyRange_Checked(object sender, RoutedEventArgs e) + { + if (sender is not RadioButton button || !int.TryParse(button.Tag as string, out var index)) + { + return; + } + + _hourlyRangeIndex = index; + if (!_isInitialized) + { + return; + } + + RefreshHourly(); + App.CurrentApp?.TrackClick("year_review_hourly_range", new Dictionary + { + ["range"] = index switch { 0 => "today", 1 => "week", 2 => "month", _ => "all" } + }); + } + + private void HourlyMetric_Checked(object sender, RoutedEventArgs e) + { + if (!_isInitialized) + { + return; + } + + RefreshHourly(); + App.CurrentApp?.TrackClick("year_review_hourly_metric", new Dictionary + { + ["metric"] = IsHourlyClicksMetric ? "clicks" : "keys" + }); + } + + private void RefreshAchievements() + { + if (!_isInitialized) + { + return; + } + + var service = AchievementService.Instance; + var progress = AchievementEvaluator.Evaluate(_history.Values, StatsManager.Instance.GetMetersPerPixel()); + var culture = CultureInfo.CurrentCulture; + var cards = progress.Select(item => + { + var unlockDate = item.IsUnlocked ? service.GetUnlockDate(item) : null; + return new AchievementCard( + AchievementService.GetName(item.Definition.Id), + AchievementService.GetDescription(item.Definition), + GlyphFor(item.Definition.Category), + item.IsUnlocked, + unlockDate.HasValue + ? string.Format(culture, Strings.Achievement_UnlockedOnFormat, + unlockDate.Value.ToString(Strings.Achievement_DateFormat, CultureInfo.CurrentUICulture)) + : string.Empty, + item.Definition.Target > 0 ? Math.Min(1, item.Current / item.Definition.Target) : 0, + FormatProgress(item)); + }).ToList(); + + AchievementsList.ItemsSource = cards; + AchievementSummaryText.Text = string.Format(culture, Strings.Achievement_UnlockedSummaryFormat, + cards.Count(card => card.IsUnlocked), cards.Count); + AchievementStreakText.Text = string.Format(culture, Strings.Achievement_CurrentStreakFormat, + AchievementEvaluator.CurrentStreak(_history, DateTime.Today)); + } + + private static string FormatProgress(AchievementProgress item) + { + var culture = CultureInfo.CurrentCulture; + var current = Math.Min(item.Current, item.Definition.Target); + return item.Definition.Category switch + { + AchievementCategory.MouseDistanceKm => $"{current.ToString("0.0", culture)} / {item.Definition.Target.ToString("0.###", culture)} km", + AchievementCategory.PeakKps => $"{current.ToString("0.0", culture)} / {item.Definition.Target.ToString("N0", culture)}", + _ => $"{current.ToString("N0", culture)} / {item.Definition.Target.ToString("N0", culture)}" + }; + } + + private static string GlyphFor(AchievementCategory category) => category switch + { + AchievementCategory.TotalKeys => "", + AchievementCategory.DailyKeys => "", + AchievementCategory.TotalClicks => "", + AchievementCategory.Streak => "", + AchievementCategory.ActiveDays => "", + AchievementCategory.PeakKps => "", + AchievementCategory.MouseDistanceKm => "", + _ => "" + }; + + private sealed class AchievementCard + { + public AchievementCard(string name, string description, string glyph, bool isUnlocked, string unlockedText, double progress, string progressText) + { + Name = name; + Description = description; + Glyph = glyph; + IsUnlocked = isUnlocked; + UnlockedText = unlockedText; + ProgressText = progressText; + FilledWidth = new GridLength(Math.Max(0, progress), GridUnitType.Star); + RemainingWidth = new GridLength(Math.Max(0, 1 - progress), GridUnitType.Star); + } + + public string Name { get; } + public string Description { get; } + public string Glyph { get; } + public bool IsUnlocked { get; } + public bool IsLocked => !IsUnlocked; + public string UnlockedText { get; } + public string ProgressText { get; } + public GridLength FilledWidth { get; } + public GridLength RemainingWidth { get; } + public double CardOpacity => IsUnlocked ? 1.0 : 0.78; + public Brush BadgeBackground => IsUnlocked + ? (Brush)Application.Current.Resources["AccentBrush"] + : (Brush)Application.Current.Resources["SubtleFillBrush"]; + public Brush BadgeForeground => IsUnlocked + ? Brushes.White + : (Brush)Application.Current.Resources["TextSecondaryBrush"]; + } + + private sealed class RangeOption + { + public RangeOption(int id, string label) + { + Id = id; + Label = label; + } + + public int Id { get; } + + public string Label { get; } + } + + private sealed class PersonaMetric + { + public PersonaMetric(string title, string value, string detail) + { + Title = title; + Value = value; + Detail = detail; + } + + public string Title { get; } + + public string Value { get; } + + public string Detail { get; } + } +} diff --git a/KeyStats.xcodeproj/project.pbxproj b/KeyStats.xcodeproj/project.pbxproj index 520dc5a..1dd6ec5 100644 --- a/KeyStats.xcodeproj/project.pbxproj +++ b/KeyStats.xcodeproj/project.pbxproj @@ -43,6 +43,9 @@ A1000032 /* StatsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000032 /* StatsModels.swift */; }; A1000033 /* KPSDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000033 /* KPSDetailView.swift */; }; A1000034 /* UpdateCheckCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000034 /* UpdateCheckCoordinator.swift */; }; + D1000001 /* YearReviewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000001 /* YearReviewModels.swift */; }; + D1000002 /* YearReviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000002 /* YearReviewWindowController.swift */; }; + D1000003 /* AchievementManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000003 /* AchievementManager.swift */; }; B1000001 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000001 /* SyncModels.swift */; }; B1000002 /* SyncCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000002 /* SyncCrypto.swift */; }; B1000003 /* SyncStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000003 /* SyncStorage.swift */; }; @@ -112,6 +115,9 @@ A2000027 /* MouseDistanceCalibrationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseDistanceCalibrationViewController.swift; sourceTree = ""; }; A2000028 /* MouseDistanceCalibrationWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseDistanceCalibrationWindowController.swift; sourceTree = ""; }; A2000029 /* UpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManager.swift; sourceTree = ""; }; + D2000001 /* YearReviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearReviewModels.swift; sourceTree = ""; }; + D2000002 /* YearReviewWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearReviewWindowController.swift; sourceTree = ""; }; + D2000003 /* AchievementManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementManager.swift; sourceTree = ""; }; A2000030 /* KeyboardHeatmapWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHeatmapWindowController.swift; sourceTree = ""; }; A2000031 /* KeyboardHeatmapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHeatmapViewController.swift; sourceTree = ""; }; A2000032 /* StatsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsModels.swift; sourceTree = ""; }; @@ -202,6 +208,9 @@ A2000025 /* AppStatsViewController.swift */, A2000030 /* KeyboardHeatmapWindowController.swift */, A2000031 /* KeyboardHeatmapViewController.swift */, + D2000001 /* YearReviewModels.swift */, + D2000002 /* YearReviewWindowController.swift */, + D2000003 /* AchievementManager.swift */, A2000008 /* MainWindowController.swift */, A2000009 /* MainWindowViewController.swift */, A2000016 /* HoverIconButton.swift */, @@ -413,6 +422,9 @@ A1000034 /* UpdateCheckCoordinator.swift in Sources */, A1000030 /* KeyboardHeatmapWindowController.swift in Sources */, A1000031 /* KeyboardHeatmapViewController.swift in Sources */, + D1000001 /* YearReviewModels.swift in Sources */, + D1000002 /* YearReviewWindowController.swift in Sources */, + D1000003 /* AchievementManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/KeyStats/AchievementManager.swift b/KeyStats/AchievementManager.swift new file mode 100644 index 0000000..0314fb7 --- /dev/null +++ b/KeyStats/AchievementManager.swift @@ -0,0 +1,133 @@ +import Foundation + +extension Notification.Name { + static let achievementsDidChange = Notification.Name("KeyStatsAchievementsDidChange") +} + +/// 成就名称与描述文案,界面和通知共用。 +enum AchievementText { + static func name(for definition: AchievementDefinition) -> String { + NSLocalizedString("achievement.name.\(definition.id)", comment: "") + } + + static func description(for definition: AchievementDefinition) -> String { + let format = NSLocalizedString("achievement.desc.\(definition.category.rawValue)", comment: "") + return String(format: format, formattedValue(definition.target, category: definition.category)) + } + + /// 数值按类别格式化:公里保留最多 3 位小数,其余取整并加千分位。 + static func formattedValue(_ value: Double, category: AchievementCategory) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: Bundle.main.preferredLocalizations.first ?? "en") + if category == .mouseDistance { + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 3 + } else { + formatter.maximumFractionDigits = 0 + } + let safeValue = value.isFinite ? value : 0 + let display = category == .mouseDistance ? safeValue : safeValue.rounded(.down) + return formatter.string(from: NSNumber(value: display)) ?? "\(display)" + } +} + +/// 成就评估、解锁记录持久化与解锁通知。 +/// 启动后评估一次,之后每 60 秒评估一次;首次评估时静默补记已达成的成就,不发通知。 +final class AchievementManager { + static let shared = AchievementManager() + + private let userDefaults = UserDefaults.standard + private let unlockedKey = "unlockedAchievements" + private let initializedKey = "achievementsInitialized" + private let notificationsEnabledKey = "achievementNotificationsEnabled" + private let evaluationInterval: TimeInterval = 60 + private var timer: Timer? + + private let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private init() {} + + /// 成就解锁通知开关,默认开启。 + var notificationsEnabled: Bool { + get { userDefaults.object(forKey: notificationsEnabledKey) as? Bool ?? true } + set { userDefaults.set(newValue, forKey: notificationsEnabledKey) } + } + + /// 已记录的解锁日期(id → yyyy-MM-dd)。 + var unlockedDates: [String: Date] { + let stored = userDefaults.dictionary(forKey: unlockedKey) as? [String: String] ?? [:] + return stored.compactMapValues { dayFormatter.date(from: $0) } + } + + /// 必须在主线程调用(Timer 挂在主 run loop)。 + func start() { + guard timer == nil else { return } + evaluateNow() + let timer = Timer(timeInterval: evaluationInterval, repeats: true) { [weak self] _ in + self?.evaluateNow() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stop() { + timer?.invalidate() + timer = nil + } + + /// 本机历史 + 今天(不含其他同步设备)。 + func dayInputs() -> [AchievementDayInput] { + StatsManager.shared.localSyncHistorySnapshot().values.map { stats in + AchievementDayInput(date: stats.date, + keyPresses: stats.keyPresses, + clicks: stats.totalClicks, + mouseDistance: stats.mouseDistance, + peakKPS: Double(stats.peakKPS)) + } + } + + func currentProgress(days: [AchievementDayInput]? = nil) -> [AchievementProgress] { + AchievementEvaluator.evaluate(days: days ?? dayInputs(), + metersPerPixel: StatsManager.shared.mouseDistanceMetersPerPixel) + } + + @discardableResult + func evaluateNow() -> [AchievementProgress] { + let progress = currentProgress() + var stored = userDefaults.dictionary(forKey: unlockedKey) as? [String: String] ?? [:] + let isInitialized = userDefaults.bool(forKey: initializedKey) + + var newlyUnlocked: [AchievementProgress] = [] + for item in progress { + guard let unlockedOn = item.unlockedOn, stored[item.definition.id] == nil else { continue } + stored[item.definition.id] = dayFormatter.string(from: unlockedOn) + newlyUnlocked.append(item) + } + + if !newlyUnlocked.isEmpty { + userDefaults.set(stored, forKey: unlockedKey) + NotificationCenter.default.post(name: .achievementsDidChange, object: nil) + } + + if !isInitialized { + // 首次启用:已达成的成就静默补记,避免一口气弹一堆通知 + userDefaults.set(true, forKey: initializedKey) + } else if notificationsEnabled { + for item in newlyUnlocked { + let title = String(format: NSLocalizedString("achievement.notification.titleFormat", comment: ""), + AchievementText.name(for: item.definition)) + NotificationManager.shared.sendAchievementNotification(identifier: item.definition.id, + title: title, + body: AchievementText.description(for: item.definition)) + } + } + return progress + } +} diff --git a/KeyStats/AppDelegate.swift b/KeyStats/AppDelegate.swift index ef77647..0743f43 100644 --- a/KeyStats/AppDelegate.swift +++ b/KeyStats/AppDelegate.swift @@ -41,6 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { applyAppIcon() _ = UpdateManager.shared SyncCoordinator.shared.start() + AchievementManager.shared.start() setupWindowMenu() @@ -52,6 +53,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { HelperXPCClient.shared.stopMonitoring() HelperXPCClient.shared.disconnect() permissionCheckTimer?.invalidate() + AchievementManager.shared.stop() StatsManager.shared.flushPendingSave() } diff --git a/KeyStats/NotificationManager.swift b/KeyStats/NotificationManager.swift index 701c5b2..0348aa8 100644 --- a/KeyStats/NotificationManager.swift +++ b/KeyStats/NotificationManager.swift @@ -34,6 +34,31 @@ final class NotificationManager { center.add(request, withCompletionHandler: nil) } + /// 成就解锁通知。尚未询问过授权时先请求授权,授权后再发送,避免第一条通知被丢弃。 + func sendAchievementNotification(identifier: String, title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + let request = UNNotificationRequest(identifier: "achievement.\(identifier)", content: content, trigger: nil) + + center.getNotificationSettings { [weak self] settings in + guard let self = self else { return } + switch settings.authorizationStatus { + case .notDetermined: + self.center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + if granted { + self.center.add(request, withCompletionHandler: nil) + } + } + case .denied: + return + default: + self.center.add(request, withCompletionHandler: nil) + } + } + } + private func thresholdBody(for metric: Metric, count: Int) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/KeyStats/SettingsViewController.swift b/KeyStats/SettingsViewController.swift index f919848..08c6b7d 100644 --- a/KeyStats/SettingsViewController.swift +++ b/KeyStats/SettingsViewController.swift @@ -55,6 +55,7 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { private var syncStatusLabel: NSTextField! private var manageSyncButton: NSButton! private var showThresholdsButton: NSSwitch! + private var achievementNotificationsButton: NSSwitch! private var thresholdStack: NSStackView! private var keyPressThresholdField: NSTextField! private var keyPressThresholdStepper: NSStepper! @@ -276,6 +277,7 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { appStatsEnabledButton = makeSwitch(action: #selector(toggleAppStatsEnabled)) launchAtLoginButton = makeSwitch(action: #selector(toggleLaunchAtLogin)) showThresholdsButton = makeSwitch(action: #selector(toggleShowThresholds)) + achievementNotificationsButton = makeSwitch(action: #selector(toggleAchievementNotifications)) dynamicIconColorButton = makeSwitch(action: #selector(toggleDynamicIconColor)) dynamicIconColorHelpButton = NSButton() @@ -379,6 +381,8 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { addArrangedSubviewFillingWidth(makeOptionDividerContainer(), to: notificationThresholdContainer) addArrangedSubviewFillingWidth(thresholdIndentStack, to: notificationThresholdContainer) addArrangedSubviewFillingWidth(notificationThresholdContainer, to: notificationStack) + addArrangedSubviewFillingWidth(makeOptionDividerContainer(), to: notificationStack) + addArrangedSubviewFillingWidth(makeSwitchRow(titleKey: "setting.achievementNotifications", toggle: achievementNotificationsButton), to: notificationStack) let notificationSection = makeSection(titleKey: "settings.section.notifications", content: notificationStack) addArrangedSubviewFillingWidth(notificationSection, to: contentStack) @@ -787,6 +791,7 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { let notificationsEnabled = StatsManager.shared.notificationsEnabled showThresholdsButton.state = notificationsEnabled ? .on : .off notificationThresholdContainer.isHidden = !notificationsEnabled + achievementNotificationsButton.state = AchievementManager.shared.notificationsEnabled ? .on : .off updateThresholdUI() updateHeatmapColorSchemeState() updateSyncState() @@ -1128,6 +1133,15 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { } } + @objc private func toggleAchievementNotifications() { + let enabled = achievementNotificationsButton.state == .on + AchievementManager.shared.notificationsEnabled = enabled + if enabled { + NotificationManager.shared.requestAuthorizationIfNeeded() + } + AppDelegate.trackClick("setting_achievement_notifications", properties: ["enabled": enabled]) + } + @objc private func toggleShowKeyPresses() { StatsManager.shared.showKeyPressesInMenuBar = (showKeyPressesButton.state == .on) } diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index 383aadd..bf48dad 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -1692,6 +1692,11 @@ extension StatsManager { ) } + /// 年度回顾用:本机历史(含今天)与已同步设备汇总后的按天数据,key 为 yyyy-MM-dd。 + func yearReviewHistorySnapshot() -> [String: DailyStats] { + displayHistorySnapshot() + } + private func displayHistorySnapshot() -> [String: DailyStats] { let local = localSyncHistorySnapshot() let syncState = SyncCoordinator.shared.state diff --git a/KeyStats/StatsPopoverViewController.swift b/KeyStats/StatsPopoverViewController.swift index 6a4a042..4ffd3d8 100644 --- a/KeyStats/StatsPopoverViewController.swift +++ b/KeyStats/StatsPopoverViewController.swift @@ -45,6 +45,7 @@ class StatsPopoverViewController: NSViewController { private var settingsButton: NSButton! private var appStatsButton: NSButton! private var allTimeStatsButton: NSButton! + private var yearReviewButton: NSButton! private var keyboardHeatmapButton: NSButton! private var pendingStatsRefresh = false private var statsUpdateToken: UUID? @@ -442,6 +443,25 @@ class StatsPopoverViewController: NSViewController { allTimeStatsButton.heightAnchor.constraint(equalToConstant: 28) ]) + yearReviewButton = makeSymbolButton(systemName: "calendar", + fallbackTitle: NSLocalizedString("yearReview.button", comment: ""), + pointSize: 16, + weight: .semibold, + action: #selector(showYearReview)) + yearReviewButton.toolTip = NSLocalizedString("yearReview.button", comment: "") + yearReviewButton.setAccessibilityLabel(NSLocalizedString("yearReview.button", comment: "")) + yearReviewButton.imageScaling = .scaleProportionallyDown + yearReviewButton.setContentHuggingPriority(.required, for: .horizontal) + yearReviewButton.setContentCompressionResistancePriority(.required, for: .horizontal) + if let hoverButton = yearReviewButton as? HoverIconButton { + hoverButton.padding = 4 + hoverButton.cornerRadius = 6 + } + NSLayoutConstraint.activate([ + yearReviewButton.widthAnchor.constraint(equalToConstant: 28), + yearReviewButton.heightAnchor.constraint(equalToConstant: 28) + ]) + let footerStack = NSStackView() footerStack.orientation = .horizontal footerStack.alignment = .centerY @@ -458,9 +478,11 @@ class StatsPopoverViewController: NSViewController { footerStack.addArrangedSubview(settingsButton) footerStack.addArrangedSubview(appStatsButton) footerStack.addArrangedSubview(allTimeStatsButton) + footerStack.addArrangedSubview(yearReviewButton) footerStack.setCustomSpacing(6, after: settingsButton) footerStack.setCustomSpacing(6, after: appStatsButton) footerStack.setCustomSpacing(6, after: allTimeStatsButton) + footerStack.setCustomSpacing(6, after: yearReviewButton) footerStack.addArrangedSubview(footerSpacer) footerStack.addArrangedSubview(buttonStack) @@ -898,6 +920,12 @@ class StatsPopoverViewController: NSViewController { view.window?.performClose(nil) } + @objc private func showYearReview() { + AppDelegate.trackClick("open_year_review") + YearReviewWindowController.shared.show() + view.window?.performClose(nil) + } + @objc private func showAppStats() { AppDelegate.trackClick("open_app_stats") AppStatsWindowController.shared.show() diff --git a/KeyStats/YearReviewModels.swift b/KeyStats/YearReviewModels.swift new file mode 100644 index 0000000..13e9996 --- /dev/null +++ b/KeyStats/YearReviewModels.swift @@ -0,0 +1,694 @@ +import Foundation + +// MARK: - 键盘人格 + +enum KeyboardPersonaKind: String, CaseIterable { + case shortcutMaster + case copyPasteEngineer + case perfectionist + case arrowKeyWalker + case mousePerson + case keyboardPurist + case balancedPlayer +} + +struct KeyboardPersonaKeyCount: Equatable { + let name: String + let count: Int +} + +struct KeyboardPersonaResult: Equatable { + let totalKeyPresses: Int + let totalClicks: Int + let shortcutCount: Int + let regretCount: Int + let copyCount: Int + let pasteCount: Int + let undoCount: Int + let arrowCount: Int + let topKeys: [KeyboardPersonaKeyCount] + /// 主称号;总按键不足 `KeyboardPersonaAnalyzer.minimumKeyPresses` 时为 nil。 + let primary: KeyboardPersonaKind? + /// 其余命中的称号,按优先级排列。 + let secondary: [KeyboardPersonaKind] + + var hasEnoughData: Bool { primary != nil } + var shortcutRatio: Double { ratio(shortcutCount) } + var regretRatio: Double { ratio(regretCount) } + var arrowRatio: Double { ratio(arrowCount) } + + /// 每次按键对应的点击次数;没有按键时为 0。 + var clicksPerKeyPress: Double { + guard totalKeyPresses > 0 else { return 0 } + return Double(totalClicks) / Double(totalKeyPresses) + } + + private func ratio(_ count: Int) -> Double { + guard totalKeyPresses > 0 else { return 0 } + return Double(count) / Double(totalKeyPresses) + } +} + +/// 根据按键分布推断「键盘人格」。纯函数,不依赖 UI,Windows 端使用同一套阈值。 +enum KeyboardPersonaAnalyzer { + static let minimumKeyPresses = 200 + static let topKeyLimit = 3 + + private static let shortcutModifiers: Set = ["Cmd", "Ctrl", "Option"] + private static let regretKeys: Set = ["Delete", "ForwardDelete"] + private static let arrowKeys: Set = ["Up", "Down", "Left", "Right"] + private static let modifierDisplayOrder = ["Cmd", "Ctrl", "Option", "Shift", "Fn"] + + static func analyze(keyCounts: [String: Int], totalKeyPresses: Int, totalClicks: Int) -> KeyboardPersonaResult { + var shortcutCount = 0 + var regretCount = 0 + var copyCount = 0 + var pasteCount = 0 + var undoCount = 0 + var arrowCount = 0 + var mergedCounts: [String: Int] = [:] + + for (rawName, rawCount) in keyCounts { + let count = max(0, rawCount) + guard count > 0 else { continue } + let parsed = parseKeyName(rawName) + guard !parsed.base.isEmpty else { continue } + + if !parsed.modifiers.isDisjoint(with: shortcutModifiers) { + shortcutCount += count + } + if regretKeys.contains(parsed.base) { + regretCount += count + } + if arrowKeys.contains(parsed.base) { + arrowCount += count + } + if parsed.modifiers == ["Cmd"] { + switch parsed.base.uppercased() { + case "C": copyCount += count + case "V": pasteCount += count + case "Z": undoCount += count + default: break + } + } + mergedCounts[parsed.displayName, default: 0] += count + } + + let topKeys = mergedCounts + .filter { $0.key != "Space" } + .sorted { lhs, rhs in + lhs.value != rhs.value ? lhs.value > rhs.value : lhs.key < rhs.key + } + .prefix(topKeyLimit) + .map { KeyboardPersonaKeyCount(name: $0.key, count: $0.value) } + + let keys = max(0, totalKeyPresses) + let clicks = max(0, totalClicks) + var matched: [KeyboardPersonaKind] = [] + if keys >= minimumKeyPresses { + // 用整数比较阈值,避免浮点误差导致恰好卡在阈值上时判定不一致 + if shortcutCount * 100 >= keys * 12 { matched.append(.shortcutMaster) } + if pasteCount >= 30 && pasteCount >= copyCount * 2 { matched.append(.copyPasteEngineer) } + if regretCount * 10 >= keys { matched.append(.perfectionist) } + if arrowCount * 100 >= keys * 8 { matched.append(.arrowKeyWalker) } + if clicks >= keys { matched.append(.mousePerson) } + if clicks * 100 <= keys * 15 { matched.append(.keyboardPurist) } + if matched.isEmpty { matched.append(.balancedPlayer) } + } + + return KeyboardPersonaResult( + totalKeyPresses: keys, + totalClicks: clicks, + shortcutCount: shortcutCount, + regretCount: regretCount, + copyCount: copyCount, + pasteCount: pasteCount, + undoCount: undoCount, + arrowCount: arrowCount, + topKeys: Array(topKeys), + primary: matched.first, + secondary: Array(matched.dropFirst()) + ) + } + + /// 拆出修饰键与基础键。左右修饰键统一归一,例如 "LeftCmd+C" → (["Cmd"], "C")。 + static func parseKeyName(_ keyName: String) -> (modifiers: Set, base: String, displayName: String) { + let trimmed = keyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return ([], "", "") } + if trimmed == "+" { return ([], "+", "+") } + + var body = trimmed + var literalPlus = false + if body.hasSuffix("++") { + // "Cmd++" 表示基础键就是 "+" + body = String(body.dropLast(2)) + literalPlus = true + } + + var parts = body + .split(separator: "+") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + if literalPlus { + parts.append("+") + } + guard let base = parts.last else { return ([], "", "") } + + let modifiers = Set(parts.dropLast().map(normalizedModifier)) + let orderedModifiers = modifierDisplayOrder.filter { modifiers.contains($0) } + + modifiers.subtracting(modifierDisplayOrder).sorted() + let displayName = (orderedModifiers + [base]).joined(separator: "+") + return (modifiers, base, displayName) + } + + private static func normalizedModifier(_ token: String) -> String { + switch token { + case "Cmd", "LeftCmd", "RightCmd", "Command": + return "Cmd" + case "Ctrl", "Control", "LeftCtrl", "RightCtrl": + return "Ctrl" + case "Option", "LeftOption", "RightOption", "Alt": + return "Option" + case "Shift", "LeftShift", "RightShift": + return "Shift" + default: + return token + } + } +} + +// MARK: - 年度贡献墙 + +enum ContributionWallRange: Equatable { + case last12Months + case year(Int) + + var analyticsValue: String { + switch self { + case .last12Months: + return "last_12_months" + case .year(let year): + return String(year) + } + } +} + +struct ContributionWallDay: Equatable { + let date: Date + /// 第几周(从 0 开始) + let column: Int + /// 周一为 0,周日为 6 + let row: Int + let value: Int +} + +struct ContributionWallMonthLabel: Equatable { + let column: Int + /// 1~12 + let month: Int +} + +struct ContributionWallBestDay: Equatable { + let date: Date + let value: Int +} + +struct ContributionWallModel: Equatable { + let range: ContributionWallRange + let columnCount: Int + let days: [ContributionWallDay] + let monthLabels: [ContributionWallMonthLabel] + let maxValue: Int + /// 视图内所有大于 0 的值的第 25 / 50 / 75 百分位,用于 GitHub 式分档 + let quartiles: (q1: Int, q2: Int, q3: Int) + let total: Int + let activeDays: Int + /// 视图不包含今天时为 nil + let currentStreak: Int? + let longestStreak: Int + let bestDay: ContributionWallBestDay? + + /// GitHub 式四分位分档:0 → 0(底色),其余落在 0.25 / 0.5 / 0.75 / 1.0 四档。 + func intensity(for value: Int) -> Double { + guard value > 0 else { return 0 } + if value <= quartiles.q1 { return 0.25 } + if value <= quartiles.q2 { return 0.5 } + if value <= quartiles.q3 { return 0.75 } + return 1.0 + } + + static func == (lhs: ContributionWallModel, rhs: ContributionWallModel) -> Bool { + lhs.range == rhs.range + && lhs.columnCount == rhs.columnCount + && lhs.days == rhs.days + && lhs.monthLabels == rhs.monthLabels + && lhs.maxValue == rhs.maxValue + && lhs.quartiles == rhs.quartiles + && lhs.total == rhs.total + && lhs.activeDays == rhs.activeDays + && lhs.currentStreak == rhs.currentStreak + && lhs.longestStreak == rhs.longestStreak + && lhs.bestDay == rhs.bestDay + } +} + +enum ContributionWallBuilder { + /// 「过去 12 个月」+ 从今年到历史最早年份的各自然年(倒序)。 + static func availableRanges(earliestDate: Date?, today: Date, calendar: Calendar = .current) -> [ContributionWallRange] { + let currentYear = calendar.component(.year, from: today) + let earliestYear = earliestDate.map { min(calendar.component(.year, from: $0), currentYear) } ?? currentYear + let years = stride(from: currentYear, through: earliestYear, by: -1).map { ContributionWallRange.year($0) } + return [.last12Months] + years + } + + static func build(range: ContributionWallRange, + today: Date, + calendar: Calendar = .current, + value: (Date) -> Int) -> ContributionWallModel { + let todayStart = calendar.startOfDay(for: today) + let gridStart: Date + let firstVisible: Date + let lastVisible: Date + let columnCount: Int + + switch range { + case .last12Months: + gridStart = addDays(-52 * 7, to: mondayOnOrBefore(todayStart, calendar: calendar), calendar: calendar) + firstVisible = gridStart + lastVisible = todayStart + columnCount = 53 + case .year(let year): + let jan1 = calendar.date(from: DateComponents(year: year, month: 1, day: 1)) ?? todayStart + let dec31 = calendar.date(from: DateComponents(year: year, month: 12, day: 31)) ?? todayStart + gridStart = mondayOnOrBefore(jan1, calendar: calendar) + firstVisible = jan1 + lastVisible = min(dec31, todayStart) + columnCount = daysBetween(gridStart, mondayOnOrBefore(dec31, calendar: calendar), calendar: calendar) / 7 + 1 + } + + var days: [ContributionWallDay] = [] + var date = firstVisible + while date <= lastVisible { + let index = daysBetween(gridStart, date, calendar: calendar) + days.append(ContributionWallDay(date: date, + column: index / 7, + row: index % 7, + value: max(0, value(date)))) + date = addDays(1, to: date, calendar: calendar) + } + + var monthLabels: [ContributionWallMonthLabel] = [] + var lastLabeledColumn = -1 + var lastMonth = 0 + for day in days where day.column != lastLabeledColumn { + // 每列只看最上面一天:该月第一次出现的列打标签 + lastLabeledColumn = day.column + let month = calendar.component(.month, from: day.date) + if month != lastMonth { + monthLabels.append(ContributionWallMonthLabel(column: day.column, month: month)) + lastMonth = month + } + } + + var total = 0 + var activeDays = 0 + var longestStreak = 0 + var runningStreak = 0 + var bestDay: ContributionWallBestDay? + for day in days { + total += day.value + if day.value > 0 { + activeDays += 1 + runningStreak += 1 + longestStreak = max(longestStreak, runningStreak) + } else { + runningStreak = 0 + } + if day.value > (bestDay?.value ?? 0) { + bestDay = ContributionWallBestDay(date: day.date, value: day.value) + } + } + + let sortedPositive = days.map(\.value).filter { $0 > 0 }.sorted() + let quartiles = (q1: percentile(sortedPositive, 0.25), + q2: percentile(sortedPositive, 0.50), + q3: percentile(sortedPositive, 0.75)) + + var currentStreak: Int? + if todayStart >= firstVisible && todayStart <= lastVisible { + currentStreak = streakEnding(at: todayStart, calendar: calendar, value: value) + } + + return ContributionWallModel(range: range, + columnCount: columnCount, + days: days, + monthLabels: monthLabels, + maxValue: bestDay?.value ?? 0, + quartiles: quartiles, + total: total, + activeDays: activeDays, + currentStreak: currentStreak, + longestStreak: longestStreak, + bestDay: bestDay) + } + + /// 从今天往回数连续有数据的天数;今天还是 0 时从昨天开始数(不受视图范围限制)。 + static func streakEnding(at today: Date, calendar: Calendar = .current, value: (Date) -> Int) -> Int { + var date = calendar.startOfDay(for: today) + if value(date) <= 0 { + date = addDays(-1, to: date, calendar: calendar) + } + var streak = 0 + // 上限约 30 年,防止异常数据导致死循环 + while streak < 11_000 && value(date) > 0 { + streak += 1 + date = addDays(-1, to: date, calendar: calendar) + } + return streak + } + + /// 升序数组的百分位:取下标 (n-1)*p 向下取整的元素;空数组返回 0。 + static func percentile(_ sortedValues: [Int], _ p: Double) -> Int { + guard !sortedValues.isEmpty else { return 0 } + let index = Int(Double(sortedValues.count - 1) * p) + return sortedValues[max(0, min(sortedValues.count - 1, index))] + } + + static func mondayOnOrBefore(_ date: Date, calendar: Calendar = .current) -> Date { + let start = calendar.startOfDay(for: date) + // weekday:1 = 周日 … 7 = 周六;换算成周一为 0 + let offset = (calendar.component(.weekday, from: start) + 5) % 7 + return addDays(-offset, to: start, calendar: calendar) + } + + private static func addDays(_ days: Int, to date: Date, calendar: Calendar) -> Date { + let shifted = calendar.date(byAdding: .day, value: days, to: date) ?? date.addingTimeInterval(TimeInterval(days) * 86_400) + return calendar.startOfDay(for: shifted) + } + + private static func daysBetween(_ from: Date, _ to: Date, calendar: Calendar) -> Int { + calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day ?? 0 + } +} + +// MARK: - 活跃时段 + +enum HourlyActivityMetric { + case keyPresses + case clicks +} + +/// 某一天按本地钟点汇总的 24 格计数(下标 = 小时)。 +struct HourlyDayCounts: Equatable { + var keys: [Int] + var clicks: [Int] + + init(keys: [Int] = Array(repeating: 0, count: 24), clicks: [Int] = Array(repeating: 0, count: 24)) { + self.keys = HourlyDayCounts.normalized(keys) + self.clicks = HourlyDayCounts.normalized(clicks) + } + + /// 按键与点击两项合计 > 0 才算「有小时数据」的一天(与当前指标无关)。 + var hasData: Bool { + saturatingNonnegativeSum(keys + clicks) > 0 + } + + func values(for metric: HourlyActivityMetric) -> [Int] { + metric == .keyPresses ? keys : clicks + } + + /// 长度不是 24 时视为全 0;负数按 0 处理。 + static func normalized(_ values: [Int]) -> [Int] { + guard values.count == 24 else { return Array(repeating: 0, count: 24) } + return values.map { max(0, $0) } + } +} + +enum Chronotype: String, CaseIterable { + case nightOwl + case earlyBird + case afternoonPerson + case eveningPerson +} + +struct HourlyActivitySummary: Equatable { + /// 参与统计的天数(有小时数据的天,即使当前指标全 0 也计入)。 + let dayCount: Int + /// 当前指标的逐小时之和。 + let hourlyTotals: [Int] + /// 逐小时日均 = 逐小时之和 / dayCount。 + let hourlyAverages: [Double] + let total: Int + /// 逐小时之和最大的小时(并列取更早的);全 0 时为 nil。 + let peakHour: Int? + /// 22:00–05:00 的占比;总量为 0 时为 nil。 + let nightRatio: Double? + /// 作息类型;总量 < `HourlyActivityAnalyzer.minimumTotal` 时为 nil。 + let chronotype: Chronotype? + + var hasData: Bool { dayCount > 0 } +} + +/// 根据逐小时数据推断作息类型。纯函数,Windows 端使用同一套规则。 +enum HourlyActivityAnalyzer { + static let minimumTotal = 200 + static let nightHours: [Int] = [22, 23, 0, 1, 2, 3, 4] + static let morningHours = 5...10 + static let afternoonHours = 11...17 + static let eveningHours = 18...21 + + static func analyze(days: [HourlyDayCounts], metric: HourlyActivityMetric) -> HourlyActivitySummary { + let included = days.filter { $0.hasData } + var totals = Array(repeating: 0, count: 24) + for day in included { + let values = day.values(for: metric) + for hour in 0..<24 { + totals[hour] = saturatingNonnegativeSum([totals[hour], values[hour]]) + } + } + + let total = saturatingNonnegativeSum(totals) + let averages = included.isEmpty + ? Array(repeating: 0.0, count: 24) + : totals.map { Double($0) / Double(included.count) } + + var peakHour: Int? + if total > 0, let maxValue = totals.max() { + peakHour = totals.firstIndex(of: maxValue) + } + + let nightTotal = saturatingNonnegativeSum(nightHours.map { totals[$0] }) + let nightRatio: Double? = total > 0 ? Double(nightTotal) / Double(total) : nil + + return HourlyActivitySummary(dayCount: included.count, + hourlyTotals: totals, + hourlyAverages: averages, + total: total, + peakHour: peakHour, + nightRatio: nightRatio, + chronotype: chronotype(totals: totals, total: total, nightTotal: nightTotal)) + } + + private static func chronotype(totals: [Int], total: Int, nightTotal: Int) -> Chronotype? { + guard total >= minimumTotal else { return nil } + // 整数比较,避免恰好 20% 时的浮点误差 + if nightTotal * 5 >= total { + return .nightOwl + } + let morning = saturatingNonnegativeSum(morningHours.map { totals[$0] }) + let afternoon = saturatingNonnegativeSum(afternoonHours.map { totals[$0] }) + let evening = saturatingNonnegativeSum(eveningHours.map { totals[$0] }) + // 并列取更早的时段 + if morning >= afternoon && morning >= evening { + return .earlyBird + } + return afternoon >= evening ? .afternoonPerson : .eveningPerson + } +} + +extension HourlyStats { + /// 按记录时区把 [start, end] 内每天拆成 24 格;早于开始记录的日子不返回。 + /// DST 重复的钟点合并到同一格。 + func dailyHourCounts(from start: Date, through end: Date, now: Date = Date()) -> [HourlyDayCounts] { + let recordingCalendar = calendar + let last = min(end, now) + var cursor = recordingCalendar.startOfDay(for: max(start, startedAt)) + var result: [HourlyDayCounts] = [] + // 上限约 30 年,防止异常数据导致死循环 + while cursor <= last && result.count < 11_000 { + var day = HourlyDayCounts() + for point in points(on: cursor, recent24Hours: false, now: now) { + guard let counts = point.counts else { continue } + let hour = recordingCalendar.component(.hour, from: point.date) + guard (0..<24).contains(hour) else { continue } + day.keys[hour] = saturatingNonnegativeSum([day.keys[hour], counts.keys]) + day.clicks[hour] = saturatingNonnegativeSum([day.clicks[hour], counts.clicks]) + } + result.append(day) + guard let next = recordingCalendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = recordingCalendar.startOfDay(for: next) + } + return result + } +} + +// MARK: - 成就 + +enum AchievementCategory: String, CaseIterable { + case totalKeys + case dailyKeys + case totalClicks + case streak + case activeDays + case peakKPS + case mouseDistance +} + +struct AchievementDefinition: Equatable { + let id: String + let category: AchievementCategory + /// 目标值;鼠标距离单位为公里,其余为次数 / 天数 / 键每秒。 + let target: Double +} + +enum AchievementCatalog { + static let marathonKilometers = 42.195 + + static let all: [AchievementDefinition] = [ + AchievementDefinition(id: "keys_100k", category: .totalKeys, target: 100_000), + AchievementDefinition(id: "keys_1m", category: .totalKeys, target: 1_000_000), + AchievementDefinition(id: "keys_10m", category: .totalKeys, target: 10_000_000), + AchievementDefinition(id: "day_10k", category: .dailyKeys, target: 10_000), + AchievementDefinition(id: "day_30k", category: .dailyKeys, target: 30_000), + AchievementDefinition(id: "day_50k", category: .dailyKeys, target: 50_000), + AchievementDefinition(id: "clicks_100k", category: .totalClicks, target: 100_000), + AchievementDefinition(id: "clicks_1m", category: .totalClicks, target: 1_000_000), + AchievementDefinition(id: "streak_7", category: .streak, target: 7), + AchievementDefinition(id: "streak_30", category: .streak, target: 30), + AchievementDefinition(id: "streak_100", category: .streak, target: 100), + AchievementDefinition(id: "active_100", category: .activeDays, target: 100), + AchievementDefinition(id: "active_365", category: .activeDays, target: 365), + AchievementDefinition(id: "kps_10", category: .peakKPS, target: 10), + AchievementDefinition(id: "kps_15", category: .peakKPS, target: 15), + AchievementDefinition(id: "marathon", category: .mouseDistance, target: marathonKilometers), + ] +} + +/// 成就评估所需的单日数据。 +struct AchievementDayInput: Equatable { + let date: Date + let keyPresses: Int + let clicks: Int + /// 像素距离,换算公里时乘以「米/像素」校准值。 + let mouseDistance: Double + let peakKPS: Double + + var isActive: Bool { keyPresses > 0 || clicks > 0 } +} + +struct AchievementProgress: Equatable { + let definition: AchievementDefinition + /// 当前进度:累计类为总和,单日/峰值类为历史最大值,连续为最长连续天数。 + let current: Double + /// 首次达成的日期;未达成为 nil。 + let unlockedOn: Date? + + var isUnlocked: Bool { unlockedOn != nil } + + var fraction: Double { + guard definition.target > 0 else { return 0 } + return min(1, max(0, current / definition.target)) + } +} + +/// 按时间顺序扫描每日数据,计算每个成就的进度和首次达成日期。纯函数,Windows 端规则一致。 +enum AchievementEvaluator { + static func evaluate(days: [AchievementDayInput], + metersPerPixel: Double, + definitions: [AchievementDefinition] = AchievementCatalog.all, + calendar: Calendar = .current) -> [AchievementProgress] { + let sorted = days.sorted { $0.date < $1.date } + var totalKeys = 0.0 + var totalClicks = 0.0 + var totalKilometers = 0.0 + var activeDays = 0.0 + var maxDailyKeys = 0.0 + var maxKPS = 0.0 + var currentStreak = 0 + var longestStreak = 0 + var lastActiveDay: Date? + var unlocked: [String: Date] = [:] + let safeMetersPerPixel = metersPerPixel.isFinite && metersPerPixel > 0 ? metersPerPixel : 0 + + func value(for category: AchievementCategory) -> Double { + switch category { + case .totalKeys: return totalKeys + case .dailyKeys: return maxDailyKeys + case .totalClicks: return totalClicks + case .streak: return Double(longestStreak) + case .activeDays: return activeDays + case .peakKPS: return maxKPS + case .mouseDistance: return totalKilometers + } + } + + for day in sorted { + let dayStart = calendar.startOfDay(for: day.date) + totalKeys += Double(max(0, day.keyPresses)) + totalClicks += Double(max(0, day.clicks)) + maxDailyKeys = max(maxDailyKeys, Double(max(0, day.keyPresses))) + if day.peakKPS.isFinite { + maxKPS = max(maxKPS, day.peakKPS) + } + if day.mouseDistance.isFinite && day.mouseDistance > 0 { + totalKilometers += day.mouseDistance * safeMetersPerPixel / 1000 + } + + if day.isActive { + if let last = lastActiveDay { + let gap = daysBetween(last, dayStart, calendar: calendar) + if gap == 1 { + currentStreak += 1 + } else if gap > 1 { + // 中间断了一天以上,重新计数 + currentStreak = 1 + } + // gap == 0:同一天重复的数据,不重复计数 + if gap != 0 { + activeDays += 1 + } + } else { + currentStreak = 1 + activeDays += 1 + } + lastActiveDay = dayStart + longestStreak = max(longestStreak, currentStreak) + } + + for definition in definitions where unlocked[definition.id] == nil { + // 公里数是浮点累加,留一点容差,避免恰好达标时因舍入误差漏判 + if value(for: definition.category) >= definition.target - 1e-9 { + unlocked[definition.id] = dayStart + } + } + } + + return definitions.map { definition in + AchievementProgress(definition: definition, + current: value(for: definition.category), + unlockedOn: unlocked[definition.id]) + } + } + + /// 当前连续活跃天数:从今天往回数,今天还没活跃时从昨天开始数。 + static func currentStreak(days: [AchievementDayInput], today: Date, calendar: Calendar = .current) -> Int { + let activeDays = Set(days.filter { $0.isActive }.map { calendar.startOfDay(for: $0.date) }) + return ContributionWallBuilder.streakEnding(at: today, calendar: calendar) { date in + activeDays.contains(calendar.startOfDay(for: date)) ? 1 : 0 + } + } + + private static func daysBetween(_ from: Date, _ to: Date, calendar: Calendar) -> Int { + calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day ?? 0 + } +} diff --git a/KeyStats/YearReviewWindowController.swift b/KeyStats/YearReviewWindowController.swift new file mode 100644 index 0000000..a9a9fc9 --- /dev/null +++ b/KeyStats/YearReviewWindowController.swift @@ -0,0 +1,1570 @@ +import Cocoa + +private func yearReviewLocalized(_ key: String) -> String { + NSLocalizedString(key, comment: "") +} + +/// 界面日期/星期/月份使用 App 当前语言,而不是系统区域,避免中英混排。 +private func yearReviewDisplayLocale() -> Locale { + Locale(identifier: Bundle.main.preferredLocalizations.first ?? "en") +} + +private func yearReviewFormattedCount(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = yearReviewDisplayLocale() + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" +} + +private func yearReviewPercent(_ ratio: Double) -> String { + String(format: "%.1f%%", ratio * 100) +} + +private func yearReviewIsDark(_ view: NSView) -> Bool { + view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua +} + +// MARK: - Window + +final class YearReviewWindowController: NSWindowController { + static let shared = YearReviewWindowController() + + private init() { + let viewController = YearReviewViewController() + let window = NSWindow(contentViewController: viewController) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.title = yearReviewLocalized("yearReview.windowTitle") + window.titleVisibility = .hidden + window.titlebarSeparatorStyle = .none + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + window.isMovableByWindowBackground = true + window.backgroundColor = .windowBackgroundColor + window.setContentSize(NSSize(width: 900, height: 600)) + window.minSize = NSSize(width: 820, height: 560) + window.isReleasedWhenClosed = false + window.center() + + super.init(window: window) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + guard let window = window else { return } + NSApp.activate(ignoringOtherApps: true) + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + + if let vc = contentViewController as? YearReviewViewController { + vc.refreshData() + } + AppDelegate.trackPageView("year_review") + } +} + +// MARK: - Root view controller + +final class YearReviewViewController: NSViewController { + private var titleLabel: NSTextField! + private var tabControl: NSSegmentedControl! + private var wallTabView: ContributionWallTabView! + private var personaTabView: KeyboardPersonaTabView! + private var hourlyTabView: HourlyActivityTabView! + private var achievementsTabView: AchievementsTabView! + private var achievementsObserver: NSObjectProtocol? + + private static let tabAnalyticsValues = ["wall", "persona", "hourly", "achievements"] + + deinit { + if let observer = achievementsObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 900, height: 600)) + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + refreshData() + } + + func refreshData() { + let history = StatsManager.shared.yearReviewHistorySnapshot() + wallTabView.apply(history: history) + personaTabView.apply(history: history) + hourlyTabView.apply(hourly: StatsManager.shared.hourlyStatsSnapshot()) + achievementsTabView.reload() + } + + private func setupUI() { + titleLabel = NSTextField(labelWithString: yearReviewLocalized("yearReview.windowTitle")) + titleLabel.font = NSFont.systemFont(ofSize: 22, weight: .bold) + titleLabel.textColor = .labelColor + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + tabControl = NSSegmentedControl(labels: [yearReviewLocalized("yearReview.tab.wall"), + yearReviewLocalized("yearReview.tab.persona"), + yearReviewLocalized("yearReview.tab.hourly"), + yearReviewLocalized("yearReview.tab.achievements")], + trackingMode: .selectOne, + target: self, + action: #selector(tabChanged)) + tabControl.selectedSegment = 0 + tabControl.translatesAutoresizingMaskIntoConstraints = false + + wallTabView = ContributionWallTabView() + wallTabView.translatesAutoresizingMaskIntoConstraints = false + personaTabView = KeyboardPersonaTabView() + personaTabView.translatesAutoresizingMaskIntoConstraints = false + personaTabView.isHidden = true + hourlyTabView = HourlyActivityTabView() + hourlyTabView.translatesAutoresizingMaskIntoConstraints = false + hourlyTabView.isHidden = true + achievementsTabView = AchievementsTabView() + achievementsTabView.translatesAutoresizingMaskIntoConstraints = false + achievementsTabView.isHidden = true + + view.addSubview(titleLabel) + view.addSubview(tabControl) + view.addSubview(wallTabView) + view.addSubview(personaTabView) + view.addSubview(hourlyTabView) + view.addSubview(achievementsTabView) + + achievementsObserver = NotificationCenter.default.addObserver( + forName: .achievementsDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.achievementsTabView.reload() + } + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 36), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 28), + + tabControl.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + tabControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -28), + tabControl.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 16), + ]) + + for tabView in tabViews { + NSLayoutConstraint.activate([ + tabView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + tabView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 28), + tabView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -28), + tabView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -24), + ]) + } + } + + private var tabViews: [NSView] { + [wallTabView, personaTabView, hourlyTabView, achievementsTabView] + } + + @objc private func tabChanged() { + let selected = max(0, min(tabViews.count - 1, tabControl.selectedSegment)) + for (index, tabView) in tabViews.enumerated() { + tabView.isHidden = index != selected + } + AppDelegate.trackClick("year_review_tab", properties: ["tab": Self.tabAnalyticsValues[selected]]) + } +} + +// MARK: - Shared card views + +/// 圆角玻璃卡片。layer 颜色在 updateLayer 中按当前 appearance 重新解析,深浅色切换后自动更新。 +class YearReviewCardView: NSView { + var fillAlpha: CGFloat = 0.75 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var wantsUpdateLayer: Bool { true } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func updateLayer() { + guard let layer = layer else { return } + effectiveAppearance.performAsCurrentDrawingAppearance { + layer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(fillAlpha).cgColor + layer.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor + layer.shadowColor = NSColor.black.withAlphaComponent(0.07).cgColor + } + layer.cornerRadius = 12 + layer.borderWidth = 0.5 + layer.masksToBounds = false + layer.shadowOpacity = 1 + layer.shadowRadius = 8 + layer.shadowOffset = NSSize(width: 0, height: -1) + } +} + +/// 标题 + 大号数值 + 说明 的统计卡片。 +final class YearReviewStatCard: YearReviewCardView { + private let titleLabel = NSTextField(labelWithString: "") + private let valueLabel = NSTextField(labelWithString: "") + private let detailLabel = NSTextField(labelWithString: "") + + init(title: String) { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + + titleLabel.stringValue = title + titleLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium) + titleLabel.textColor = .secondaryLabelColor + + valueLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 20, weight: .semibold) + valueLabel.textColor = .labelColor + valueLabel.lineBreakMode = .byTruncatingTail + + detailLabel.font = NSFont.systemFont(ofSize: 11) + detailLabel.textColor = .secondaryLabelColor + detailLabel.lineBreakMode = .byTruncatingTail + + let stack = NSStackView(views: [titleLabel, valueLabel, detailLabel]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor, constant: 12), + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -14), + stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -12), + ]) + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + detailLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(value: String, detail: String?) { + valueLabel.stringValue = value + detailLabel.stringValue = detail ?? "" + detailLabel.isHidden = (detail ?? "").isEmpty + } +} + +/// 胶囊标签,用于人格副称号。 +final class YearReviewTagView: NSView { + private let label = NSTextField(labelWithString: "") + + init(text: String) { + super.init(frame: .zero) + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = false + label.stringValue = text + label.font = NSFont.systemFont(ofSize: 12, weight: .medium) + label.textColor = .controlAccentColor + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: topAnchor, constant: 3), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3), + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var wantsUpdateLayer: Bool { true } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func layout() { + super.layout() + layer?.cornerRadius = bounds.height / 2 + } + + override func updateLayer() { + guard let layer = layer else { return } + effectiveAppearance.performAsCurrentDrawingAppearance { + layer.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.14).cgColor + } + layer.cornerRadius = bounds.height / 2 + } +} + +// MARK: - Contribution wall tab + +final class ContributionWallTabView: NSView { + private enum Metric: Int { + case keyPresses = 0 + case clicks = 1 + + var analyticsValue: String { self == .keyPresses ? "key_presses" : "clicks" } + } + + private let rangeControl = NSSegmentedControl(frame: .zero) + private var metricControl: NSSegmentedControl! + private let totalCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.summary.total")) + private let activeDaysCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.summary.activeDays")) + private let currentStreakCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.summary.currentStreak")) + private let longestStreakCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.summary.longestStreak")) + private let bestDayCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.summary.bestDay")) + private let wallCard = YearReviewCardView() + private let wallView = ContributionWallView() + private let legendView = ContributionWallLegendView() + + private var history: [String: DailyStats] = [:] + private var ranges: [ContributionWallRange] = [.last12Months] + private var selectedRange: ContributionWallRange = .last12Months + private var metric: Metric = .keyPresses + + private let dayKeyFormatter: DateFormatter = { + // 与 StatsManager 的历史 key 格式保持一致 + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private lazy var tooltipDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = yearReviewDisplayLocale() + formatter.setLocalizedDateFormatFromTemplate("yMMMdEEE") + return formatter + }() + + private lazy var shortDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = yearReviewDisplayLocale() + formatter.setLocalizedDateFormatFromTemplate("yMMMd") + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(history: [String: DailyStats]) { + self.history = history + rebuildRangeMenu() + reload() + } + + private func setupUI() { + rangeControl.trackingMode = .selectOne + rangeControl.target = self + rangeControl.action = #selector(rangeChanged) + rangeControl.translatesAutoresizingMaskIntoConstraints = false + + metricControl = NSSegmentedControl(labels: [yearReviewLocalized("yearReview.metric.keyPresses"), + yearReviewLocalized("yearReview.metric.clicks")], + trackingMode: .selectOne, + target: self, + action: #selector(metricChanged)) + metricControl.selectedSegment = 0 + metricControl.translatesAutoresizingMaskIntoConstraints = false + + let controlsRow = NSStackView(views: [rangeControl, NSView(), metricControl]) + controlsRow.orientation = .horizontal + controlsRow.alignment = .centerY + controlsRow.spacing = 12 + controlsRow.translatesAutoresizingMaskIntoConstraints = false + + let summaryRow = NSStackView(views: [totalCard, activeDaysCard, currentStreakCard, longestStreakCard, bestDayCard]) + summaryRow.orientation = .horizontal + summaryRow.distribution = .fillEqually + summaryRow.spacing = 10 + summaryRow.translatesAutoresizingMaskIntoConstraints = false + + wallCard.translatesAutoresizingMaskIntoConstraints = false + wallView.translatesAutoresizingMaskIntoConstraints = false + legendView.translatesAutoresizingMaskIntoConstraints = false + + let lessLabel = NSTextField(labelWithString: yearReviewLocalized("settings.heatmapPreview.less")) + let moreLabel = NSTextField(labelWithString: yearReviewLocalized("settings.heatmapPreview.more")) + for label in [lessLabel, moreLabel] { + label.font = NSFont.systemFont(ofSize: 11) + label.textColor = .secondaryLabelColor + } + let legendRow = NSStackView(views: [lessLabel, legendView, moreLabel]) + legendRow.orientation = .horizontal + legendRow.alignment = .centerY + legendRow.spacing = 6 + legendRow.translatesAutoresizingMaskIntoConstraints = false + + wallCard.addSubview(wallView) + wallCard.addSubview(legendRow) + + addSubview(controlsRow) + addSubview(summaryRow) + addSubview(wallCard) + + NSLayoutConstraint.activate([ + controlsRow.topAnchor.constraint(equalTo: topAnchor), + controlsRow.leadingAnchor.constraint(equalTo: leadingAnchor), + controlsRow.trailingAnchor.constraint(equalTo: trailingAnchor), + + summaryRow.topAnchor.constraint(equalTo: controlsRow.bottomAnchor, constant: 14), + summaryRow.leadingAnchor.constraint(equalTo: leadingAnchor), + summaryRow.trailingAnchor.constraint(equalTo: trailingAnchor), + summaryRow.heightAnchor.constraint(equalToConstant: 82), + + wallCard.topAnchor.constraint(equalTo: summaryRow.bottomAnchor, constant: 14), + wallCard.leadingAnchor.constraint(equalTo: leadingAnchor), + wallCard.trailingAnchor.constraint(equalTo: trailingAnchor), + wallCard.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + + wallView.topAnchor.constraint(equalTo: wallCard.topAnchor, constant: 16), + wallView.leadingAnchor.constraint(equalTo: wallCard.leadingAnchor, constant: 16), + wallView.trailingAnchor.constraint(equalTo: wallCard.trailingAnchor, constant: -16), + wallView.heightAnchor.constraint(equalToConstant: 140), + + legendRow.topAnchor.constraint(equalTo: wallView.bottomAnchor, constant: 10), + legendRow.trailingAnchor.constraint(equalTo: wallCard.trailingAnchor, constant: -16), + legendRow.bottomAnchor.constraint(equalTo: wallCard.bottomAnchor, constant: -14), + legendView.widthAnchor.constraint(equalToConstant: ContributionWallLegendView.preferredWidth), + legendView.heightAnchor.constraint(equalToConstant: ContributionWallLegendView.cellSize), + ]) + + wallView.tooltipProvider = { [weak self] day in + self?.tooltipText(for: day) ?? "" + } + } + + private func rebuildRangeMenu() { + let earliest = history.values + .filter { $0.keyPresses > 0 || $0.totalClicks > 0 } + .map { $0.date } + .min() + ranges = ContributionWallBuilder.availableRanges(earliestDate: earliest, today: Date()) + if !ranges.contains(selectedRange) { + selectedRange = .last12Months + } + + // 范围用分段控件平铺展示,和指标、人格范围的切换样式保持一致 + rangeControl.segmentCount = ranges.count + for (index, range) in ranges.enumerated() { + let label: String + switch range { + case .last12Months: + label = yearReviewLocalized("yearReview.range.last12Months") + case .year(let year): + label = String(format: yearReviewLocalized("yearReview.range.yearFormat"), year) + } + rangeControl.setLabel(label, forSegment: index) + rangeControl.setWidth(0, forSegment: index) + } + if let index = ranges.firstIndex(of: selectedRange) { + rangeControl.selectedSegment = index + } + } + + private func reload() { + let model = ContributionWallBuilder.build(range: selectedRange, today: Date()) { [weak self] date in + self?.value(on: date) ?? 0 + } + wallView.model = model + + totalCard.update(value: yearReviewFormattedCount(model.total), detail: unitText()) + activeDaysCard.update(value: daysText(model.activeDays), detail: nil) + if let currentStreak = model.currentStreak { + currentStreakCard.update(value: daysText(currentStreak), detail: nil) + } else { + currentStreakCard.update(value: "—", detail: nil) + } + longestStreakCard.update(value: daysText(model.longestStreak), detail: nil) + if let best = model.bestDay { + bestDayCard.update(value: yearReviewFormattedCount(best.value), detail: shortDateFormatter.string(from: best.date)) + } else { + bestDayCard.update(value: "—", detail: nil) + } + } + + private func value(on date: Date) -> Int { + guard let stats = history[dayKeyFormatter.string(from: date)] else { return 0 } + switch metric { + case .keyPresses: + return stats.keyPresses + case .clicks: + return stats.totalClicks + } + } + + private func daysText(_ days: Int) -> String { + String(format: yearReviewLocalized("yearReview.daysFormat"), days) + } + + private func unitText() -> String { + metric == .keyPresses + ? yearReviewLocalized("yearReview.unit.keyPresses") + : yearReviewLocalized("yearReview.unit.clicks") + } + + private func tooltipText(for day: ContributionWallDay) -> String { + let format = metric == .keyPresses + ? yearReviewLocalized("yearReview.tooltip.keyPressesFormat") + : yearReviewLocalized("yearReview.tooltip.clicksFormat") + return String(format: format, tooltipDateFormatter.string(from: day.date), yearReviewFormattedCount(day.value)) + } + + @objc private func rangeChanged() { + let index = rangeControl.selectedSegment + guard index >= 0, index < ranges.count else { return } + selectedRange = ranges[index] + reload() + AppDelegate.trackClick("year_review_range", properties: ["range": selectedRange.analyticsValue]) + } + + @objc private func metricChanged() { + metric = Metric(rawValue: metricControl.selectedSegment) ?? .keyPresses + reload() + AppDelegate.trackClick("year_review_metric", properties: ["metric": metric.analyticsValue]) + } +} + +/// 贡献墙网格:列为周、行为周一到周日,颜色跟随热力图配色。 +final class ContributionWallView: NSView, NSViewToolTipOwner { + private let weekdayLabelWidth: CGFloat = 34 + private let monthLabelHeight: CGFloat = 18 + private let cellGap: CGFloat = 3 + private let maxCellSize: CGFloat = 16 + private var schemeObserver: NSObjectProtocol? + + var model: ContributionWallModel? { + didSet { + needsLayout = true + needsDisplay = true + } + } + + var tooltipProvider: ((ContributionWallDay) -> String)? + + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + schemeObserver = NotificationCenter.default.addObserver( + forName: .heatmapColorSchemeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let observer = schemeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func layout() { + super.layout() + rebuildToolTips() + } + + private var cellSize: CGFloat { + let columns = CGFloat(max(1, model?.columnCount ?? 53)) + let widthFit = (bounds.width - weekdayLabelWidth - cellGap * (columns - 1)) / columns + let heightFit = (bounds.height - monthLabelHeight - cellGap * 6) / 7 + return max(4, min(maxCellSize, widthFit, heightFit)) + } + + private func cellRect(column: Int, row: Int) -> NSRect { + let size = cellSize + return NSRect(x: weekdayLabelWidth + CGFloat(column) * (size + cellGap), + y: monthLabelHeight + CGFloat(row) * (size + cellGap), + width: size, + height: size) + } + + private func rebuildToolTips() { + removeAllToolTips() + guard let model = model else { return } + for day in model.days { + _ = addToolTip(cellRect(column: day.column, row: day.row), owner: self, userData: nil) + } + } + + func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer?) -> String { + guard let model = model, + let day = model.days.first(where: { cellRect(column: $0.column, row: $0.row).insetBy(dx: -cellGap / 2, dy: -cellGap / 2).contains(point) }) + else { return "" } + return tooltipProvider?(day) ?? "" + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let model = model else { return } + + let isDarkMode = yearReviewIsDark(self) + let locale = yearReviewDisplayLocale() + let formatter = DateFormatter() + formatter.locale = locale + let labelAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10), + .foregroundColor: NSColor.secondaryLabelColor, + ] + let size = cellSize + + // 月份标签:相邻太近时跳过,避免重叠 + let monthSymbols = formatter.shortStandaloneMonthSymbols ?? formatter.shortMonthSymbols ?? [] + var lastLabelMaxX = -CGFloat.greatestFiniteMagnitude + for label in model.monthLabels where label.month >= 1 && label.month <= monthSymbols.count { + let text = monthSymbols[label.month - 1] as NSString + let textSize = text.size(withAttributes: labelAttributes) + let x = cellRect(column: label.column, row: 0).minX + guard x >= lastLabelMaxX + 4, x + textSize.width <= bounds.maxX else { continue } + text.draw(at: NSPoint(x: x, y: (monthLabelHeight - textSize.height) / 2 - 2), withAttributes: labelAttributes) + lastLabelMaxX = x + textSize.width + } + + // 星期标签:只标周一 / 周三 / 周五(shortWeekdaySymbols 从周日开始) + let weekdaySymbols = formatter.shortStandaloneWeekdaySymbols ?? formatter.shortWeekdaySymbols ?? [] + if weekdaySymbols.count == 7 { + for (row, symbolIndex) in [(0, 1), (2, 3), (4, 5)] { + let text = weekdaySymbols[symbolIndex] as NSString + let textSize = text.size(withAttributes: labelAttributes) + let rect = cellRect(column: 0, row: row) + text.draw(at: NSPoint(x: 0, y: rect.midY - textSize.height / 2), withAttributes: labelAttributes) + } + } + + let borderColor = isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06) + let radius = min(3, size * 0.22) + for day in model.days { + let rect = cellRect(column: day.column, row: day.row) + guard rect.intersects(dirtyRect) else { continue } + let fillColor = day.value > 0 + ? HeatmapPalette.color(intensity: CGFloat(model.intensity(for: day.value)), isDarkMode: isDarkMode) + : HeatmapPalette.neutralColor(isDarkMode: isDarkMode) + let path = NSBezierPath(roundedRect: rect.insetBy(dx: 0.25, dy: 0.25), xRadius: radius, yRadius: radius) + fillColor.setFill() + path.fill() + borderColor.setStroke() + path.lineWidth = 0.5 + path.stroke() + } + } +} + +/// 图例:底色 + 4 档热度。 +final class ContributionWallLegendView: NSView { + static let cellSize: CGFloat = 11 + static let gap: CGFloat = 3 + static let intensities: [CGFloat] = [0, 0.25, 0.5, 0.75, 1.0] + static var preferredWidth: CGFloat { + CGFloat(intensities.count) * cellSize + CGFloat(intensities.count - 1) * gap + } + + private var schemeObserver: NSObjectProtocol? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + schemeObserver = NotificationCenter.default.addObserver( + forName: .heatmapColorSchemeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let observer = schemeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let isDarkMode = yearReviewIsDark(self) + let borderColor = isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06) + for (index, intensity) in Self.intensities.enumerated() { + let rect = NSRect(x: CGFloat(index) * (Self.cellSize + Self.gap), + y: (bounds.height - Self.cellSize) / 2, + width: Self.cellSize, + height: Self.cellSize) + let fillColor = intensity == 0 + ? HeatmapPalette.neutralColor(isDarkMode: isDarkMode) + : HeatmapPalette.color(intensity: intensity, isDarkMode: isDarkMode) + let path = NSBezierPath(roundedRect: rect.insetBy(dx: 0.25, dy: 0.25), xRadius: 2.5, yRadius: 2.5) + fillColor.setFill() + path.fill() + borderColor.setStroke() + path.lineWidth = 0.5 + path.stroke() + } + } +} + +// MARK: - Keyboard persona tab + +final class KeyboardPersonaTabView: NSView { + private enum PersonaRange: Int, CaseIterable { + case today = 0 + case week + case month + case all + + var analyticsValue: String { + switch self { + case .today: return "today" + case .week: return "week" + case .month: return "month" + case .all: return "all" + } + } + + /// nil 表示全部历史 + var dayCount: Int? { + switch self { + case .today: return 1 + case .week: return 7 + case .month: return 30 + case .all: return nil + } + } + } + + private var rangeControl: NSSegmentedControl! + private let heroCard = YearReviewCardView() + private let personaTitleLabel = NSTextField(labelWithString: "") + private let personaCommentLabel = NSTextField(labelWithString: "") + private let totalsLabel = NSTextField(labelWithString: "") + private let tagsRow = NSStackView() + private let shortcutCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.shortcut")) + private let regretCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.regret")) + private let copyPasteCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.copyPaste")) + private let undoCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.undo")) + private let arrowCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.arrow")) + private let clickRatioCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.clickRatio")) + private let topKeysCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.persona.metric.topKeys")) + + private var history: [String: DailyStats] = [:] + private var range: PersonaRange = .month + + private let dayKeyFormatter: DateFormatter = { + // 与 StatsManager 的历史 key 格式保持一致 + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(history: [String: DailyStats]) { + self.history = history + reload() + } + + private func setupUI() { + rangeControl = NSSegmentedControl(labels: [yearReviewLocalized("yearReview.persona.range.today"), + yearReviewLocalized("yearReview.persona.range.week"), + yearReviewLocalized("yearReview.persona.range.month"), + yearReviewLocalized("yearReview.persona.range.all")], + trackingMode: .selectOne, + target: self, + action: #selector(rangeChanged)) + rangeControl.selectedSegment = range.rawValue + rangeControl.translatesAutoresizingMaskIntoConstraints = false + + personaTitleLabel.font = NSFont.systemFont(ofSize: 26, weight: .bold) + personaTitleLabel.textColor = .labelColor + personaTitleLabel.lineBreakMode = .byWordWrapping + personaTitleLabel.maximumNumberOfLines = 2 + + personaCommentLabel.font = NSFont.systemFont(ofSize: 14) + personaCommentLabel.textColor = .secondaryLabelColor + + totalsLabel.font = NSFont.systemFont(ofSize: 11) + totalsLabel.textColor = .tertiaryLabelColor + + tagsRow.orientation = .horizontal + tagsRow.spacing = 6 + tagsRow.alignment = .centerY + + let heroStack = NSStackView(views: [personaTitleLabel, personaCommentLabel, tagsRow, totalsLabel]) + heroStack.orientation = .vertical + heroStack.alignment = .leading + heroStack.spacing = 8 + heroStack.setCustomSpacing(12, after: personaCommentLabel) + heroStack.translatesAutoresizingMaskIntoConstraints = false + + heroCard.translatesAutoresizingMaskIntoConstraints = false + heroCard.addSubview(heroStack) + + let cards = [shortcutCard, regretCard, copyPasteCard, undoCard, arrowCard, clickRatioCard, topKeysCard] + var rows: [NSView] = [] + var index = 0 + while index < cards.count { + let right: NSView = index + 1 < cards.count ? cards[index + 1] : NSView() + let row = NSStackView(views: [cards[index], right]) + row.orientation = .horizontal + row.distribution = .fillEqually + row.spacing = 10 + row.translatesAutoresizingMaskIntoConstraints = false + // 卡片「标题 + 数值 + 说明」三行加上下内边距实测需要 84pt,低于此值说明行会被截断 + row.heightAnchor.constraint(equalToConstant: 84).isActive = true + rows.append(row) + index += 2 + } + let grid = NSStackView(views: rows) + grid.orientation = .vertical + grid.alignment = .leading + grid.spacing = 10 + grid.translatesAutoresizingMaskIntoConstraints = false + + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + let documentView = FlippedDocumentView() + documentView.translatesAutoresizingMaskIntoConstraints = false + documentView.addSubview(heroCard) + documentView.addSubview(grid) + scrollView.documentView = documentView + + addSubview(rangeControl) + addSubview(scrollView) + + NSLayoutConstraint.activate([ + rangeControl.topAnchor.constraint(equalTo: topAnchor), + rangeControl.leadingAnchor.constraint(equalTo: leadingAnchor), + + scrollView.topAnchor.constraint(equalTo: rangeControl.bottomAnchor, constant: 14), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + + documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), + documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + + heroCard.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 2), + heroCard.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), + heroCard.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), + + heroStack.topAnchor.constraint(equalTo: heroCard.topAnchor, constant: 20), + heroStack.leadingAnchor.constraint(equalTo: heroCard.leadingAnchor, constant: 22), + heroStack.trailingAnchor.constraint(lessThanOrEqualTo: heroCard.trailingAnchor, constant: -22), + heroStack.bottomAnchor.constraint(equalTo: heroCard.bottomAnchor, constant: -18), + + grid.topAnchor.constraint(equalTo: heroCard.bottomAnchor, constant: 12), + grid.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), + grid.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), + grid.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -4), + ]) + for row in rows { + row.widthAnchor.constraint(equalTo: grid.widthAnchor).isActive = true + } + } + + private func reload() { + let input = aggregate() + let result = KeyboardPersonaAnalyzer.analyze(keyCounts: input.keyCounts, + totalKeyPresses: input.keyPresses, + totalClicks: input.clicks) + + tagsRow.arrangedSubviews.forEach { view in + tagsRow.removeArrangedSubview(view) + view.removeFromSuperview() + } + + if let primary = result.primary { + personaTitleLabel.stringValue = title(for: primary) + personaCommentLabel.stringValue = comment(for: primary) + personaCommentLabel.isHidden = false + for kind in result.secondary { + tagsRow.addArrangedSubview(YearReviewTagView(text: title(for: kind))) + } + } else { + personaTitleLabel.stringValue = yearReviewLocalized("yearReview.persona.notEnoughData") + personaCommentLabel.stringValue = "" + personaCommentLabel.isHidden = true + } + tagsRow.isHidden = tagsRow.arrangedSubviews.isEmpty + totalsLabel.stringValue = String(format: yearReviewLocalized("yearReview.persona.totalsFormat"), + yearReviewFormattedCount(result.totalKeyPresses), + yearReviewFormattedCount(result.totalClicks)) + + shortcutCard.update(value: yearReviewPercent(result.shortcutRatio), + detail: yearReviewLocalized("yearReview.persona.metric.shortcut.detail")) + regretCard.update(value: yearReviewPercent(result.regretRatio), + detail: yearReviewLocalized("yearReview.persona.metric.regret.detail")) + copyPasteCard.update(value: String(format: yearReviewLocalized("yearReview.persona.copyPasteFormat"), + yearReviewFormattedCount(result.copyCount), + yearReviewFormattedCount(result.pasteCount)), + detail: nil) + undoCard.update(value: yearReviewFormattedCount(result.undoCount), detail: nil) + arrowCard.update(value: yearReviewPercent(result.arrowRatio), + detail: yearReviewLocalized("yearReview.persona.metric.arrow.detail")) + if result.totalKeyPresses > 0 { + let clicksPerHundred = String(format: "%.0f", result.clicksPerKeyPress * 100) + clickRatioCard.update(value: String(format: "%.2f", result.clicksPerKeyPress), + detail: String(format: yearReviewLocalized("yearReview.persona.clickRatioFormat"), clicksPerHundred)) + } else { + clickRatioCard.update(value: "—", detail: nil) + } + let topKeysText = result.topKeys.map { $0.name }.joined(separator: " · ") + topKeysCard.update(value: topKeysText.isEmpty ? "—" : topKeysText, + detail: result.topKeys.isEmpty ? nil : result.topKeys.map { yearReviewFormattedCount($0.count) }.joined(separator: " · ")) + } + + private func aggregate() -> (keyCounts: [String: Int], keyPresses: Int, clicks: Int) { + let selected: [DailyStats] + if let dayCount = range.dayCount { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + selected = (0.. String { + yearReviewLocalized("yearReview.persona.title.\(kind.rawValue)") + } + + private func comment(for kind: KeyboardPersonaKind) -> String { + yearReviewLocalized("yearReview.persona.comment.\(kind.rawValue)") + } + + @objc private func rangeChanged() { + range = PersonaRange(rawValue: rangeControl.selectedSegment) ?? .week + reload() + AppDelegate.trackClick("year_review_persona_range", properties: ["range": range.analyticsValue]) + } +} + +private final class FlippedDocumentView: NSView { + override var isFlipped: Bool { true } +} + +// MARK: - Hourly activity tab + +private enum YearReviewDayRange: Int, CaseIterable { + case today = 0 + case week + case month + case all + + var analyticsValue: String { + switch self { + case .today: return "today" + case .week: return "week" + case .month: return "month" + case .all: return "all" + } + } + + /// 范围起点;全部历史返回 distantPast。 + func startDate(today: Date, calendar: Calendar = .current) -> Date { + let start = calendar.startOfDay(for: today) + switch self { + case .today: return start + case .week: return calendar.date(byAdding: .day, value: -6, to: start) ?? start + case .month: return calendar.date(byAdding: .day, value: -29, to: start) ?? start + case .all: return .distantPast + } + } +} + +private func yearReviewHourRangeText(_ hour: Int) -> String { + String(format: "%02ld:00–%02ld:00", hour, (hour + 1) % 24 == 0 ? 24 : hour + 1) +} + +/// 日均值:小于 10 时保留 1 位小数,避免全部显示成 0。 +private func yearReviewFormattedAverage(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = yearReviewDisplayLocale() + formatter.maximumFractionDigits = value > 0 && value < 10 ? 1 : 0 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) +} + +final class HourlyActivityTabView: NSView { + private var rangeControl: NSSegmentedControl! + private var metricControl: NSSegmentedControl! + private let peakCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.hourly.peakHour")) + private let chronotypeCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.hourly.chronotype")) + private let nightCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.hourly.nightRatio")) + private let daysCard = YearReviewStatCard(title: yearReviewLocalized("yearReview.hourly.days")) + private let chartCard = YearReviewCardView() + private let chartView = HourlyActivityChartView() + private let emptyLabel = NSTextField(labelWithString: yearReviewLocalized("yearReview.hourly.empty")) + + private var hourly: HourlyStats? + private var range: YearReviewDayRange = .month + private var metric: HourlyActivityMetric = .keyPresses + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(hourly: HourlyStats) { + self.hourly = hourly + reload() + } + + private func setupUI() { + rangeControl = NSSegmentedControl(labels: [yearReviewLocalized("yearReview.persona.range.today"), + yearReviewLocalized("yearReview.persona.range.week"), + yearReviewLocalized("yearReview.persona.range.month"), + yearReviewLocalized("yearReview.persona.range.all")], + trackingMode: .selectOne, + target: self, + action: #selector(rangeChanged)) + rangeControl.selectedSegment = range.rawValue + rangeControl.translatesAutoresizingMaskIntoConstraints = false + + metricControl = NSSegmentedControl(labels: [yearReviewLocalized("yearReview.metric.keyPresses"), + yearReviewLocalized("yearReview.metric.clicks")], + trackingMode: .selectOne, + target: self, + action: #selector(metricChanged)) + metricControl.selectedSegment = 0 + metricControl.translatesAutoresizingMaskIntoConstraints = false + + let controlsRow = NSStackView(views: [rangeControl, NSView(), metricControl]) + controlsRow.orientation = .horizontal + controlsRow.alignment = .centerY + controlsRow.spacing = 12 + controlsRow.translatesAutoresizingMaskIntoConstraints = false + + let summaryRow = NSStackView(views: [peakCard, chronotypeCard, nightCard, daysCard]) + summaryRow.orientation = .horizontal + summaryRow.distribution = .fillEqually + summaryRow.spacing = 10 + summaryRow.translatesAutoresizingMaskIntoConstraints = false + + chartCard.translatesAutoresizingMaskIntoConstraints = false + chartView.translatesAutoresizingMaskIntoConstraints = false + emptyLabel.font = NSFont.systemFont(ofSize: 13) + emptyLabel.textColor = .secondaryLabelColor + emptyLabel.alignment = .center + emptyLabel.translatesAutoresizingMaskIntoConstraints = false + chartCard.addSubview(chartView) + chartCard.addSubview(emptyLabel) + + addSubview(controlsRow) + addSubview(summaryRow) + addSubview(chartCard) + + NSLayoutConstraint.activate([ + controlsRow.topAnchor.constraint(equalTo: topAnchor), + controlsRow.leadingAnchor.constraint(equalTo: leadingAnchor), + controlsRow.trailingAnchor.constraint(equalTo: trailingAnchor), + + summaryRow.topAnchor.constraint(equalTo: controlsRow.bottomAnchor, constant: 14), + summaryRow.leadingAnchor.constraint(equalTo: leadingAnchor), + summaryRow.trailingAnchor.constraint(equalTo: trailingAnchor), + summaryRow.heightAnchor.constraint(equalToConstant: 82), + + chartCard.topAnchor.constraint(equalTo: summaryRow.bottomAnchor, constant: 14), + chartCard.leadingAnchor.constraint(equalTo: leadingAnchor), + chartCard.trailingAnchor.constraint(equalTo: trailingAnchor), + chartCard.bottomAnchor.constraint(equalTo: bottomAnchor), + + chartView.topAnchor.constraint(equalTo: chartCard.topAnchor, constant: 16), + chartView.leadingAnchor.constraint(equalTo: chartCard.leadingAnchor, constant: 16), + chartView.trailingAnchor.constraint(equalTo: chartCard.trailingAnchor, constant: -16), + chartView.bottomAnchor.constraint(equalTo: chartCard.bottomAnchor, constant: -12), + + emptyLabel.centerXAnchor.constraint(equalTo: chartCard.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: chartCard.centerYAnchor), + emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: chartCard.leadingAnchor, constant: 20), + emptyLabel.trailingAnchor.constraint(lessThanOrEqualTo: chartCard.trailingAnchor, constant: -20), + ]) + + chartView.tooltipProvider = { [weak self] hour, average in + self?.tooltipText(hour: hour, average: average) ?? "" + } + } + + private func reload() { + let now = Date() + let days = hourly?.dailyHourCounts(from: range.startDate(today: now), through: now, now: now) ?? [] + let summary = HourlyActivityAnalyzer.analyze(days: days, metric: metric) + + chartView.averages = summary.hourlyAverages + chartView.isHidden = !summary.hasData + emptyLabel.isHidden = summary.hasData + + if let peakHour = summary.peakHour { + peakCard.update(value: yearReviewHourRangeText(peakHour), detail: nil) + } else { + peakCard.update(value: "—", detail: nil) + } + + if let chronotype = summary.chronotype { + chronotypeCard.update(value: yearReviewLocalized("yearReview.hourly.chronotype.\(chronotype.rawValue)"), + detail: yearReviewLocalized("yearReview.hourly.chronotype.\(chronotype.rawValue).comment")) + } else { + chronotypeCard.update(value: "—", + detail: summary.total > 0 ? yearReviewLocalized("yearReview.hourly.notEnoughData") : nil) + } + + if let nightRatio = summary.nightRatio { + nightCard.update(value: yearReviewPercent(nightRatio), detail: yearReviewLocalized("yearReview.hourly.nightRatio.detail")) + } else { + nightCard.update(value: "—", detail: nil) + } + + daysCard.update(value: String(format: yearReviewLocalized("yearReview.daysFormat"), summary.dayCount), + detail: summary.hasData + ? String(format: yearReviewLocalized("yearReview.hourly.daysDetailFormat"), summary.dayCount) + : nil) + } + + private func tooltipText(hour: Int, average: Double) -> String { + let format = metric == .keyPresses + ? yearReviewLocalized("yearReview.hourly.tooltip.keyPressesFormat") + : yearReviewLocalized("yearReview.hourly.tooltip.clicksFormat") + return String(format: format, yearReviewHourRangeText(hour), yearReviewFormattedAverage(average)) + } + + @objc private func rangeChanged() { + range = YearReviewDayRange(rawValue: rangeControl.selectedSegment) ?? .month + reload() + AppDelegate.trackClick("year_review_hourly_range", properties: ["range": range.analyticsValue]) + } + + @objc private func metricChanged() { + metric = metricControl.selectedSegment == 1 ? .clicks : .keyPresses + reload() + AppDelegate.trackClick("year_review_hourly_metric", properties: ["metric": metric == .keyPresses ? "keys" : "clicks"]) + } +} + +/// 24 小时柱状图:柱色按 value/max 取热力图配色,最高的柱子顶部标数值。 +final class HourlyActivityChartView: NSView, NSViewToolTipOwner { + private let axisLabelHeight: CGFloat = 20 + private let topLabelHeight: CGFloat = 18 + private let barGapRatio: CGFloat = 0.28 + private var schemeObserver: NSObjectProtocol? + + var averages: [Double] = Array(repeating: 0, count: 24) { + didSet { + needsLayout = true + needsDisplay = true + } + } + + /// (小时, 日均值) -> tooltip 文案 + var tooltipProvider: ((Int, Double) -> String)? + + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + schemeObserver = NotificationCenter.default.addObserver( + forName: .heatmapColorSchemeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let observer = schemeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func layout() { + super.layout() + rebuildToolTips() + } + + private var slotWidth: CGFloat { + max(1, bounds.width / 24) + } + + private var plotHeight: CGFloat { + max(1, bounds.height - axisLabelHeight - topLabelHeight) + } + + private func slotRect(hour: Int) -> NSRect { + NSRect(x: CGFloat(hour) * slotWidth, y: topLabelHeight, width: slotWidth, height: plotHeight) + } + + private func rebuildToolTips() { + removeAllToolTips() + guard averages.count == 24 else { return } + for hour in 0..<24 { + _ = addToolTip(slotRect(hour: hour), owner: self, userData: nil) + } + } + + func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer?) -> String { + guard averages.count == 24 else { return "" } + let hour = max(0, min(23, Int(point.x / slotWidth))) + return tooltipProvider?(hour, averages[hour]) ?? "" + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard averages.count == 24 else { return } + + let isDarkMode = yearReviewIsDark(self) + let maxValue = averages.max() ?? 0 + let maxHour = maxValue > 0 ? averages.firstIndex(of: maxValue) : nil + let labelAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10), + .foregroundColor: NSColor.secondaryLabelColor, + ] + let valueAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .semibold), + .foregroundColor: NSColor.labelColor, + ] + + // 基线 + let baselineY = topLabelHeight + plotHeight + NSColor.separatorColor.withAlphaComponent(0.4).setFill() + NSRect(x: 0, y: baselineY, width: bounds.width, height: 0.5).fill() + + let barWidth = max(2, slotWidth * (1 - barGapRatio)) + let radius = min(4, barWidth / 3) + for hour in 0..<24 { + let value = averages[hour] + let slot = slotRect(hour: hour) + let ratio = maxValue > 0 ? value / maxValue : 0 + let barHeight = value > 0 ? max(3, CGFloat(ratio) * plotHeight) : 2 + let rect = NSRect(x: slot.midX - barWidth / 2, + y: baselineY - barHeight, + width: barWidth, + height: barHeight) + let fillColor = value > 0 + ? HeatmapPalette.color(intensity: CGFloat(max(0.08, ratio)), isDarkMode: isDarkMode) + : HeatmapPalette.neutralColor(isDarkMode: isDarkMode) + fillColor.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + + if hour == maxHour { + let text = yearReviewFormattedAverage(value) as NSString + let size = text.size(withAttributes: valueAttributes) + let x = min(max(0, slot.midX - size.width / 2), bounds.width - size.width) + text.draw(at: NSPoint(x: x, y: max(0, rect.minY - size.height - 2)), withAttributes: valueAttributes) + } + + if hour % 3 == 0 { + let text = "\(hour)" as NSString + let size = text.size(withAttributes: labelAttributes) + text.draw(at: NSPoint(x: slot.midX - size.width / 2, y: baselineY + (axisLabelHeight - size.height) / 2), + withAttributes: labelAttributes) + } + } + } +} + +// MARK: - Achievements tab + +final class AchievementsTabView: NSView { + private static let columns = 4 + + private let unlockedLabel = NSTextField(labelWithString: "") + private let streakLabel = NSTextField(labelWithString: "") + private let grid = NSStackView() + + private lazy var unlockDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = yearReviewDisplayLocale() + formatter.setLocalizedDateFormatFromTemplate("yMMMd") + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func reload() { + let manager = AchievementManager.shared + let days = manager.dayInputs() + let progress = manager.currentProgress(days: days) + let storedDates = manager.unlockedDates + let unlockedCount = progress.filter { $0.isUnlocked || storedDates[$0.definition.id] != nil }.count + + unlockedLabel.stringValue = String(format: yearReviewLocalized("yearReview.achievements.unlockedFormat"), + unlockedCount, progress.count) + streakLabel.stringValue = String(format: yearReviewLocalized("yearReview.achievements.currentStreakFormat"), + AchievementEvaluator.currentStreak(days: days, today: Date())) + + grid.arrangedSubviews.forEach { view in + grid.removeArrangedSubview(view) + view.removeFromSuperview() + } + + var index = 0 + while index < progress.count { + var views: [NSView] = [] + for column in 0.. NSImage? { + let name: String + switch category { + case .totalKeys: name = "keyboard" + case .dailyKeys: name = "sun.max.fill" + case .totalClicks: name = "cursorarrow.click" + case .streak: name = "flame.fill" + case .activeDays: name = "checkmark.seal.fill" + case .peakKPS: name = "bolt.fill" + case .mouseDistance: name = "figure.run" + } + return NSImage(systemSymbolName: name, accessibilityDescription: nil) + ?? NSImage(systemSymbolName: "trophy.fill", accessibilityDescription: nil) + } + + private static func tint(for category: AchievementCategory) -> NSColor { + switch category { + case .totalKeys: return .systemBlue + case .dailyKeys: return .systemOrange + case .totalClicks: return .systemPurple + case .streak: return .systemRed + case .activeDays: return .systemGreen + case .peakKPS: return .systemYellow + case .mouseDistance: return .systemTeal + } + } +} diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index 0a86356..5966d6b 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -397,3 +397,106 @@ "settings.heatmapCustomHighColor" = "Peak Color"; "settings.heatmapPreview.less" = "Less"; "settings.heatmapPreview.more" = "More"; + +/* Year in Review */ +"yearReview.windowTitle" = "Year in Review"; +"yearReview.button" = "Year in Review"; +"yearReview.tab.wall" = "Contribution Wall"; +"yearReview.tab.persona" = "Keyboard Persona"; +"yearReview.range.last12Months" = "Past 12 Months"; +"yearReview.range.yearFormat" = "%ld"; +"yearReview.metric.keyPresses" = "Key Presses"; +"yearReview.metric.clicks" = "Mouse Clicks"; +"yearReview.summary.total" = "Total"; +"yearReview.summary.activeDays" = "Active Days"; +"yearReview.summary.currentStreak" = "Current Streak"; +"yearReview.summary.longestStreak" = "Longest Streak"; +"yearReview.summary.bestDay" = "Best Day"; +"yearReview.daysFormat" = "%ld days"; +"yearReview.unit.keyPresses" = "key presses"; +"yearReview.unit.clicks" = "clicks"; +"yearReview.tooltip.keyPressesFormat" = "%@ · %@ key presses"; +"yearReview.tooltip.clicksFormat" = "%@ · %@ clicks"; +"yearReview.persona.range.today" = "Today"; +"yearReview.persona.range.week" = "7 Days"; +"yearReview.persona.range.month" = "30 Days"; +"yearReview.persona.range.all" = "All Time"; +"yearReview.persona.notEnoughData" = "Not enough data yet. Type a little more and check back!"; +"yearReview.persona.totalsFormat" = "Based on %@ key presses · %@ clicks"; +"yearReview.persona.metric.shortcut" = "Shortcut Ratio"; +"yearReview.persona.metric.shortcut.detail" = "Combos with ⌘ / ⌃ / ⌥"; +"yearReview.persona.metric.regret" = "Regret Index"; +"yearReview.persona.metric.regret.detail" = "Share of Delete and Forward Delete"; +"yearReview.persona.metric.copyPaste" = "Copy / Paste"; +"yearReview.persona.copyPasteFormat" = "Copy %@ · Paste %@"; +"yearReview.persona.metric.undo" = "Undo Count"; +"yearReview.persona.metric.arrow" = "Arrow Key Ratio"; +"yearReview.persona.metric.arrow.detail" = "Share of ↑ ↓ ← →"; +"yearReview.persona.metric.clickRatio" = "Click-to-Key Ratio"; +"yearReview.persona.clickRatioFormat" = "%@ clicks per 100 key presses"; +"yearReview.persona.metric.topKeys" = "Most Used Keys"; +"yearReview.persona.title.shortcutMaster" = "Shortcut Master"; +"yearReview.persona.comment.shortcutMaster" = "Hands never leave the keyboard; the mouse is just decoration."; +"yearReview.persona.title.copyPasteEngineer" = "Copy-Paste Engineer"; +"yearReview.persona.comment.copyPasteEngineer" = "A mover of code and an artist of efficiency."; +"yearReview.persona.title.perfectionist" = "Perfectionist"; +"yearReview.persona.comment.perfectionist" = "Every word gets weighed again and again."; +"yearReview.persona.title.arrowKeyWalker" = "Arrow Key Walker"; +"yearReview.persona.comment.arrowKeyWalker" = "The cursor dances at your fingertips."; +"yearReview.persona.title.mousePerson" = "Mouse Person"; +"yearReview.persona.comment.mousePerson" = "Click, click, click — everything under control."; +"yearReview.persona.title.keyboardPurist" = "Keyboard Purist"; +"yearReview.persona.comment.keyboardPurist" = "If the keyboard can do it, the mouse stays untouched."; +"yearReview.persona.title.balancedPlayer" = "Balanced Player"; +"yearReview.persona.comment.balancedPlayer" = "Keyboard and mouse in harmony, perfectly paced."; + +// Activity hours & achievements +"yearReview.tab.hourly" = "Active Hours"; +"yearReview.tab.achievements" = "Achievements"; +"yearReview.hourly.peakHour" = "Peak hour"; +"yearReview.hourly.chronotype" = "Chronotype"; +"yearReview.hourly.nightRatio" = "Night share"; +"yearReview.hourly.nightRatio.detail" = "22:00–05:00"; +"yearReview.hourly.days" = "Days counted"; +"yearReview.hourly.daysDetailFormat" = "Based on %ld days of data"; +"yearReview.hourly.notEnoughData" = "Not enough data yet"; +"yearReview.hourly.empty" = "Hourly tracking starts now. Check back in a while."; +"yearReview.hourly.tooltip.keyPressesFormat" = "%@ · %@ key presses per day"; +"yearReview.hourly.tooltip.clicksFormat" = "%@ · %@ clicks per day"; +"yearReview.hourly.chronotype.nightOwl" = "Night Owl"; +"yearReview.hourly.chronotype.nightOwl.comment" = "The later it gets, the sharper you are."; +"yearReview.hourly.chronotype.earlyBird" = "Early Bird"; +"yearReview.hourly.chronotype.earlyBird.comment" = "The morning sets the tone for the day."; +"yearReview.hourly.chronotype.afternoonPerson" = "Afternoon Person"; +"yearReview.hourly.chronotype.afternoonPerson.comment" = "Full throttle right after lunch."; +"yearReview.hourly.chronotype.eveningPerson" = "Evening Person"; +"yearReview.hourly.chronotype.eveningPerson.comment" = "The real work starts after hours."; +"yearReview.achievements.unlockedFormat" = "Unlocked %ld / %ld"; +"yearReview.achievements.currentStreakFormat" = "Current streak: %ld days"; +"yearReview.achievements.unlockedOnFormat" = "Unlocked on %@"; +"yearReview.achievements.progressFormat" = "%@ / %@"; +"achievement.notification.titleFormat" = "🏆 Achievement unlocked: %@"; +"achievement.name.keys_100k" = "Warming Up"; +"achievement.name.keys_1m" = "Millionaire"; +"achievement.name.keys_10m" = "Legend"; +"achievement.name.day_10k" = "10K Day"; +"achievement.name.day_30k" = "Keyboard Storm"; +"achievement.name.day_50k" = "Human Typewriter"; +"achievement.name.clicks_100k" = "Click Expert"; +"achievement.name.clicks_1m" = "Click Harvester"; +"achievement.name.streak_7" = "Week Streak"; +"achievement.name.streak_30" = "Month Streak"; +"achievement.name.streak_100" = "100-Day Streak"; +"achievement.name.active_100" = "100 Active Days"; +"achievement.name.active_365" = "Year-Round"; +"achievement.name.kps_10" = "Quick Fingers"; +"achievement.name.kps_15" = "Lightning Fingers"; +"achievement.name.marathon" = "Mouse Marathon"; +"achievement.desc.totalKeys" = "Reach %@ total key presses"; +"achievement.desc.dailyKeys" = "Press %@ keys in a single day"; +"achievement.desc.totalClicks" = "Reach %@ total clicks"; +"achievement.desc.streak" = "Stay active %@ days in a row"; +"achievement.desc.activeDays" = "Be active on %@ days in total"; +"achievement.desc.peakKPS" = "Hit a peak of %@ keys per second"; +"achievement.desc.mouseDistance" = "Move the mouse %@ km in total"; +"setting.achievementNotifications" = "Achievement notifications"; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index 36a43dc..61e3aec 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -397,3 +397,106 @@ "settings.heatmapCustomHighColor" = "最高色"; "settings.heatmapPreview.less" = "少"; "settings.heatmapPreview.more" = "多"; + +/* Year in Review */ +"yearReview.windowTitle" = "年度回顾"; +"yearReview.button" = "年度回顾"; +"yearReview.tab.wall" = "贡献墙"; +"yearReview.tab.persona" = "键盘人格"; +"yearReview.range.last12Months" = "过去 12 个月"; +"yearReview.range.yearFormat" = "%ld 年"; +"yearReview.metric.keyPresses" = "按键次数"; +"yearReview.metric.clicks" = "鼠标点击"; +"yearReview.summary.total" = "总计"; +"yearReview.summary.activeDays" = "活跃天数"; +"yearReview.summary.currentStreak" = "当前连续"; +"yearReview.summary.longestStreak" = "最长连续"; +"yearReview.summary.bestDay" = "最高单日"; +"yearReview.daysFormat" = "%ld 天"; +"yearReview.unit.keyPresses" = "次按键"; +"yearReview.unit.clicks" = "次点击"; +"yearReview.tooltip.keyPressesFormat" = "%@ · %@ 次按键"; +"yearReview.tooltip.clicksFormat" = "%@ · %@ 次点击"; +"yearReview.persona.range.today" = "今天"; +"yearReview.persona.range.week" = "本周"; +"yearReview.persona.range.month" = "本月"; +"yearReview.persona.range.all" = "全部"; +"yearReview.persona.notEnoughData" = "数据还不够,多敲一会儿再来看看吧"; +"yearReview.persona.totalsFormat" = "基于 %@ 次按键 · %@ 次点击"; +"yearReview.persona.metric.shortcut" = "快捷键占比"; +"yearReview.persona.metric.shortcut.detail" = "带 ⌘ / ⌃ / ⌥ 的组合键"; +"yearReview.persona.metric.regret" = "后悔指数"; +"yearReview.persona.metric.regret.detail" = "退格与删除键的占比"; +"yearReview.persona.metric.copyPaste" = "复制 / 粘贴"; +"yearReview.persona.copyPasteFormat" = "复制 %@ · 粘贴 %@"; +"yearReview.persona.metric.undo" = "撤销次数"; +"yearReview.persona.metric.arrow" = "方向键占比"; +"yearReview.persona.metric.arrow.detail" = "↑ ↓ ← → 的占比"; +"yearReview.persona.metric.clickRatio" = "键鼠比"; +"yearReview.persona.clickRatioFormat" = "每 100 次按键点击 %@ 次"; +"yearReview.persona.metric.topKeys" = "最常用的键"; +"yearReview.persona.title.shortcutMaster" = "快捷键大师"; +"yearReview.persona.comment.shortcutMaster" = "手不离键盘,鼠标只是摆设"; +"yearReview.persona.title.copyPasteEngineer" = "复制粘贴工程师"; +"yearReview.persona.comment.copyPasteEngineer" = "代码的搬运工,效率的艺术家"; +"yearReview.persona.title.perfectionist" = "完美主义者"; +"yearReview.persona.comment.perfectionist" = "每个字都要反复斟酌"; +"yearReview.persona.title.arrowKeyWalker" = "方向键行者"; +"yearReview.persona.comment.arrowKeyWalker" = "光标在指尖上下翻飞"; +"yearReview.persona.title.mousePerson" = "鼠标流"; +"yearReview.persona.comment.mousePerson" = "点点点,一切尽在掌握"; +"yearReview.persona.title.keyboardPurist" = "键盘流"; +"yearReview.persona.comment.keyboardPurist" = "能用键盘解决的绝不碰鼠标"; +"yearReview.persona.title.balancedPlayer" = "均衡型选手"; +"yearReview.persona.comment.balancedPlayer" = "键鼠双修,张弛有度"; + +// Activity hours & achievements +"yearReview.tab.hourly" = "活跃时段"; +"yearReview.tab.achievements" = "成就"; +"yearReview.hourly.peakHour" = "最活跃时段"; +"yearReview.hourly.chronotype" = "作息类型"; +"yearReview.hourly.nightRatio" = "夜间占比"; +"yearReview.hourly.nightRatio.detail" = "22:00–05:00"; +"yearReview.hourly.days" = "统计天数"; +"yearReview.hourly.daysDetailFormat" = "基于 %ld 天的数据"; +"yearReview.hourly.notEnoughData" = "数据还不够"; +"yearReview.hourly.empty" = "从现在开始按小时记录,过一会儿再来看看吧"; +"yearReview.hourly.tooltip.keyPressesFormat" = "%@ · 日均 %@ 次按键"; +"yearReview.hourly.tooltip.clicksFormat" = "%@ · 日均 %@ 次点击"; +"yearReview.hourly.chronotype.nightOwl" = "夜猫子"; +"yearReview.hourly.chronotype.nightOwl.comment" = "越夜越清醒,灵感都在深夜"; +"yearReview.hourly.chronotype.earlyBird" = "早鸟"; +"yearReview.hourly.chronotype.earlyBird.comment" = "一日之计在于晨"; +"yearReview.hourly.chronotype.afternoonPerson" = "午后型"; +"yearReview.hourly.chronotype.afternoonPerson.comment" = "午饭过后火力全开"; +"yearReview.hourly.chronotype.eveningPerson" = "晚间型"; +"yearReview.hourly.chronotype.eveningPerson.comment" = "下班后才是正经时间"; +"yearReview.achievements.unlockedFormat" = "已解锁 %ld / %ld"; +"yearReview.achievements.currentStreakFormat" = "当前连续 %ld 天"; +"yearReview.achievements.unlockedOnFormat" = "%@ 解锁"; +"yearReview.achievements.progressFormat" = "%@ / %@"; +"achievement.notification.titleFormat" = "🏆 解锁成就:%@"; +"achievement.name.keys_100k" = "初露锋芒"; +"achievement.name.keys_1m" = "百万键客"; +"achievement.name.keys_10m" = "千万传奇"; +"achievement.name.day_10k" = "日敲万键"; +"achievement.name.day_30k" = "键盘风暴"; +"achievement.name.day_50k" = "人形打字机"; +"achievement.name.clicks_100k" = "点击达人"; +"achievement.name.clicks_1m" = "鼠标收割机"; +"achievement.name.streak_7" = "一周不断"; +"achievement.name.streak_30" = "月度坚持"; +"achievement.name.streak_100" = "百日筑基"; +"achievement.name.active_100" = "百日老友"; +"achievement.name.active_365" = "年度伙伴"; +"achievement.name.kps_10" = "快手"; +"achievement.name.kps_15" = "闪电手"; +"achievement.name.marathon" = "鼠标马拉松"; +"achievement.desc.totalKeys" = "累计按键达到 %@ 次"; +"achievement.desc.dailyKeys" = "单日按键达到 %@ 次"; +"achievement.desc.totalClicks" = "累计点击达到 %@ 次"; +"achievement.desc.streak" = "连续活跃 %@ 天"; +"achievement.desc.activeDays" = "累计活跃 %@ 天"; +"achievement.desc.peakKPS" = "峰值手速达到 %@ 键/秒"; +"achievement.desc.mouseDistance" = "鼠标累计移动 %@ 公里"; +"setting.achievementNotifications" = "成就解锁通知"; diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings index 116f79f..60e0703 100644 --- a/KeyStats/zh-Hant.lproj/Localizable.strings +++ b/KeyStats/zh-Hant.lproj/Localizable.strings @@ -397,3 +397,106 @@ "settings.heatmapCustomHighColor" = "最高色"; "settings.heatmapPreview.less" = "少"; "settings.heatmapPreview.more" = "多"; + +/* Year in Review */ +"yearReview.windowTitle" = "年度回顧"; +"yearReview.button" = "年度回顧"; +"yearReview.tab.wall" = "貢獻牆"; +"yearReview.tab.persona" = "鍵盤人格"; +"yearReview.range.last12Months" = "過去 12 個月"; +"yearReview.range.yearFormat" = "%ld 年"; +"yearReview.metric.keyPresses" = "按鍵次數"; +"yearReview.metric.clicks" = "滑鼠點擊"; +"yearReview.summary.total" = "總計"; +"yearReview.summary.activeDays" = "活躍天數"; +"yearReview.summary.currentStreak" = "目前連續"; +"yearReview.summary.longestStreak" = "最長連續"; +"yearReview.summary.bestDay" = "最高單日"; +"yearReview.daysFormat" = "%ld 天"; +"yearReview.unit.keyPresses" = "次按鍵"; +"yearReview.unit.clicks" = "次點擊"; +"yearReview.tooltip.keyPressesFormat" = "%@ · %@ 次按鍵"; +"yearReview.tooltip.clicksFormat" = "%@ · %@ 次點擊"; +"yearReview.persona.range.today" = "今天"; +"yearReview.persona.range.week" = "本週"; +"yearReview.persona.range.month" = "本月"; +"yearReview.persona.range.all" = "全部"; +"yearReview.persona.notEnoughData" = "資料還不夠,多敲一會兒再來看看吧"; +"yearReview.persona.totalsFormat" = "基於 %@ 次按鍵 · %@ 次點擊"; +"yearReview.persona.metric.shortcut" = "快捷鍵佔比"; +"yearReview.persona.metric.shortcut.detail" = "帶 ⌘ / ⌃ / ⌥ 的組合鍵"; +"yearReview.persona.metric.regret" = "後悔指數"; +"yearReview.persona.metric.regret.detail" = "退格與刪除鍵的佔比"; +"yearReview.persona.metric.copyPaste" = "複製 / 貼上"; +"yearReview.persona.copyPasteFormat" = "複製 %@ · 貼上 %@"; +"yearReview.persona.metric.undo" = "復原次數"; +"yearReview.persona.metric.arrow" = "方向鍵佔比"; +"yearReview.persona.metric.arrow.detail" = "↑ ↓ ← → 的佔比"; +"yearReview.persona.metric.clickRatio" = "鍵鼠比"; +"yearReview.persona.clickRatioFormat" = "每 100 次按鍵點擊 %@ 次"; +"yearReview.persona.metric.topKeys" = "最常用的鍵"; +"yearReview.persona.title.shortcutMaster" = "快捷鍵大師"; +"yearReview.persona.comment.shortcutMaster" = "手不離鍵盤,滑鼠只是擺設"; +"yearReview.persona.title.copyPasteEngineer" = "複製貼上工程師"; +"yearReview.persona.comment.copyPasteEngineer" = "程式碼的搬運工,效率的藝術家"; +"yearReview.persona.title.perfectionist" = "完美主義者"; +"yearReview.persona.comment.perfectionist" = "每個字都要反覆斟酌"; +"yearReview.persona.title.arrowKeyWalker" = "方向鍵行者"; +"yearReview.persona.comment.arrowKeyWalker" = "游標在指尖上下翻飛"; +"yearReview.persona.title.mousePerson" = "滑鼠流"; +"yearReview.persona.comment.mousePerson" = "點點點,一切盡在掌握"; +"yearReview.persona.title.keyboardPurist" = "鍵盤流"; +"yearReview.persona.comment.keyboardPurist" = "能用鍵盤解決的絕不碰滑鼠"; +"yearReview.persona.title.balancedPlayer" = "均衡型選手"; +"yearReview.persona.comment.balancedPlayer" = "鍵鼠雙修,張弛有度"; + +// Activity hours & achievements +"yearReview.tab.hourly" = "活躍時段"; +"yearReview.tab.achievements" = "成就"; +"yearReview.hourly.peakHour" = "最活躍時段"; +"yearReview.hourly.chronotype" = "作息類型"; +"yearReview.hourly.nightRatio" = "夜間佔比"; +"yearReview.hourly.nightRatio.detail" = "22:00–05:00"; +"yearReview.hourly.days" = "統計天數"; +"yearReview.hourly.daysDetailFormat" = "基於 %ld 天的資料"; +"yearReview.hourly.notEnoughData" = "資料還不夠"; +"yearReview.hourly.empty" = "從現在開始按小時記錄,過一會兒再來看看吧"; +"yearReview.hourly.tooltip.keyPressesFormat" = "%@ · 日均 %@ 次按鍵"; +"yearReview.hourly.tooltip.clicksFormat" = "%@ · 日均 %@ 次點擊"; +"yearReview.hourly.chronotype.nightOwl" = "夜貓子"; +"yearReview.hourly.chronotype.nightOwl.comment" = "越夜越清醒,靈感都在深夜"; +"yearReview.hourly.chronotype.earlyBird" = "早鳥"; +"yearReview.hourly.chronotype.earlyBird.comment" = "一日之計在於晨"; +"yearReview.hourly.chronotype.afternoonPerson" = "午後型"; +"yearReview.hourly.chronotype.afternoonPerson.comment" = "午飯過後火力全開"; +"yearReview.hourly.chronotype.eveningPerson" = "晚間型"; +"yearReview.hourly.chronotype.eveningPerson.comment" = "下班後才是正經時間"; +"yearReview.achievements.unlockedFormat" = "已解鎖 %ld / %ld"; +"yearReview.achievements.currentStreakFormat" = "目前連續 %ld 天"; +"yearReview.achievements.unlockedOnFormat" = "%@ 解鎖"; +"yearReview.achievements.progressFormat" = "%@ / %@"; +"achievement.notification.titleFormat" = "🏆 解鎖成就:%@"; +"achievement.name.keys_100k" = "初露鋒芒"; +"achievement.name.keys_1m" = "百萬鍵客"; +"achievement.name.keys_10m" = "千萬傳奇"; +"achievement.name.day_10k" = "日敲萬鍵"; +"achievement.name.day_30k" = "鍵盤風暴"; +"achievement.name.day_50k" = "人形打字機"; +"achievement.name.clicks_100k" = "點擊達人"; +"achievement.name.clicks_1m" = "滑鼠收割機"; +"achievement.name.streak_7" = "一週不斷"; +"achievement.name.streak_30" = "月度堅持"; +"achievement.name.streak_100" = "百日築基"; +"achievement.name.active_100" = "百日老友"; +"achievement.name.active_365" = "年度夥伴"; +"achievement.name.kps_10" = "快手"; +"achievement.name.kps_15" = "閃電手"; +"achievement.name.marathon" = "滑鼠馬拉松"; +"achievement.desc.totalKeys" = "累計按鍵達到 %@ 次"; +"achievement.desc.dailyKeys" = "單日按鍵達到 %@ 次"; +"achievement.desc.totalClicks" = "累計點擊達到 %@ 次"; +"achievement.desc.streak" = "連續活躍 %@ 天"; +"achievement.desc.activeDays" = "累計活躍 %@ 天"; +"achievement.desc.peakKPS" = "峰值手速達到 %@ 鍵/秒"; +"achievement.desc.mouseDistance" = "滑鼠累計移動 %@ 公里"; +"setting.achievementNotifications" = "成就解鎖通知"; diff --git a/KeyStatsTests/ActivityInsightsTests.swift b/KeyStatsTests/ActivityInsightsTests.swift new file mode 100644 index 0000000..b238acf --- /dev/null +++ b/KeyStatsTests/ActivityInsightsTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import KeyStatsCore + +final class ActivityInsightsTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int = 0) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))! + } + + /// 只在指定小时有按键的一天。 + private func day(keys: [Int: Int], clicks: [Int: Int] = [:]) -> HourlyDayCounts { + var keyValues = Array(repeating: 0, count: 24) + var clickValues = Array(repeating: 0, count: 24) + for (hour, value) in keys { keyValues[hour] = value } + for (hour, value) in clicks { clickValues[hour] = value } + return HourlyDayCounts(keys: keyValues, clicks: clickValues) + } + + private func input(_ date: Date, keys: Int = 0, clicks: Int = 0, distance: Double = 0, kps: Double = 0) -> AchievementDayInput { + AchievementDayInput(date: date, keyPresses: keys, clicks: clicks, mouseDistance: distance, peakKPS: kps) + } + + private func progress(_ id: String, in results: [AchievementProgress]) -> AchievementProgress { + results.first { $0.definition.id == id }! + } + + // MARK: - Hourly activity + + func testEmptyDaysHaveNoSummary() { + let summary = HourlyActivityAnalyzer.analyze(days: [HourlyDayCounts(), HourlyDayCounts()], metric: .keyPresses) + XCTAssertFalse(summary.hasData) + XCTAssertEqual(summary.dayCount, 0) + XCTAssertNil(summary.peakHour) + XCTAssertNil(summary.nightRatio) + XCTAssertNil(summary.chronotype) + } + + func testInvalidLengthIsTreatedAsZero() { + let counts = HourlyDayCounts(keys: [1, 2, 3], clicks: []) + XCTAssertEqual(counts.keys, Array(repeating: 0, count: 24)) + XCTAssertFalse(counts.hasData) + } + + func testDayWithOnlyClicksCountsTowardKeyAverage() { + // 第二天只有点击,按键指标下仍计入天数 + let summary = HourlyActivityAnalyzer.analyze(days: [day(keys: [9: 100]), day(keys: [:], clicks: [9: 5])], + metric: .keyPresses) + XCTAssertEqual(summary.dayCount, 2) + XCTAssertEqual(summary.hourlyAverages[9], 50, accuracy: 0.0001) + XCTAssertEqual(summary.peakHour, 9) + } + + func testPeakHourTiePicksEarlierHour() { + let summary = HourlyActivityAnalyzer.analyze(days: [day(keys: [8: 50, 15: 50])], metric: .keyPresses) + XCTAssertEqual(summary.peakHour, 8) + } + + func testNightRatioShownBelowMinimumButNoChronotype() { + let summary = HourlyActivityAnalyzer.analyze(days: [day(keys: [23: 50, 14: 50])], metric: .keyPresses) + XCTAssertEqual(summary.nightRatio ?? -1, 0.5, accuracy: 0.0001) + XCTAssertNil(summary.chronotype) + } + + func testNightOwlAtExactlyTwentyPercent() { + let summary = HourlyActivityAnalyzer.analyze(days: [day(keys: [2: 40, 14: 160])], metric: .keyPresses) + XCTAssertEqual(summary.chronotype, .nightOwl) + } + + func testJustBelowTwentyPercentNightFallsBackToLargestBucket() { + let summary = HourlyActivityAnalyzer.analyze(days: [day(keys: [2: 39, 14: 161])], metric: .keyPresses) + XCTAssertEqual(summary.chronotype, .afternoonPerson) + } + + func testEarlyBirdAndEveningPerson() { + XCTAssertEqual(HourlyActivityAnalyzer.analyze(days: [day(keys: [7: 150, 13: 50])], metric: .keyPresses).chronotype, + .earlyBird) + XCTAssertEqual(HourlyActivityAnalyzer.analyze(days: [day(keys: [19: 150, 13: 50])], metric: .keyPresses).chronotype, + .eveningPerson) + } + + func testChronotypeTieChoosesEarlierBucket() { + XCTAssertEqual(HourlyActivityAnalyzer.analyze(days: [day(keys: [8: 100, 14: 100])], metric: .keyPresses).chronotype, + .earlyBird) + XCTAssertEqual(HourlyActivityAnalyzer.analyze(days: [day(keys: [14: 100, 20: 100])], metric: .keyPresses).chronotype, + .afternoonPerson) + } + + func testClicksMetricUsesClickCounts() { + let summary = HourlyActivityAnalyzer.analyze(days: [day(keys: [8: 500], clicks: [20: 300])], metric: .clicks) + XCTAssertEqual(summary.total, 300) + XCTAssertEqual(summary.chronotype, .eveningPerson) + } + + func testHourlyStatsDailyHourCounts() { + var stats = HourlyStats(startedAt: date(2026, 10, 20), timeZone: TimeZone(secondsFromGMT: 0)!) + stats.record(keys: 3, at: date(2026, 10, 20, hour: 9)) + stats.record(clicks: 2, at: date(2026, 10, 21, hour: 23)) + let days = stats.dailyHourCounts(from: .distantPast, through: date(2026, 10, 22, hour: 12), now: date(2026, 10, 22, hour: 12)) + XCTAssertEqual(days.count, 3) + XCTAssertEqual(days[0].keys[9], 3) + XCTAssertEqual(days[1].clicks[23], 2) + XCTAssertFalse(days[2].hasData) + } + + // MARK: - Achievements + + func testCumulativeKeysRecordFirstUnlockDate() { + let results = AchievementEvaluator.evaluate(days: [ + input(date(2026, 1, 1), keys: 60_000), + input(date(2026, 1, 2), keys: 30_000), + input(date(2026, 1, 3), keys: 20_000), + ], metersPerPixel: 0.001, calendar: calendar) + let keys100k = progress("keys_100k", in: results) + XCTAssertEqual(keys100k.unlockedOn, date(2026, 1, 3)) + XCTAssertEqual(keys100k.current, 110_000, accuracy: 0.001) + XCTAssertFalse(progress("keys_1m", in: results).isUnlocked) + XCTAssertEqual(progress("keys_1m", in: results).fraction, 0.11, accuracy: 0.0001) + } + + func testDailyKeysUsesSingleDayValue() { + let results = AchievementEvaluator.evaluate(days: [ + input(date(2026, 1, 1), keys: 9_999), + input(date(2026, 1, 2), keys: 9_999), + ], metersPerPixel: 0, calendar: calendar) + XCTAssertFalse(progress("day_10k", in: results).isUnlocked) + } + + func testStreakBreaksOnGapAndKeepsLongest() { + var days: [AchievementDayInput] = [] + for offset in 0..<7 { + days.append(input(date(2026, 3, 1 + offset), keys: 10)) + } + // 3 月 8 日空缺,之后只连续 3 天 + for offset in 0..<3 { + days.append(input(date(2026, 3, 9 + offset), clicks: 1)) + } + let results = AchievementEvaluator.evaluate(days: days, metersPerPixel: 0, calendar: calendar) + XCTAssertEqual(progress("streak_7", in: results).unlockedOn, date(2026, 3, 7)) + XCTAssertEqual(progress("streak_30", in: results).current, 7, accuracy: 0.001) + XCTAssertEqual(progress("active_100", in: results).current, 10, accuracy: 0.001) + } + + func testInactiveDayEntryBreaksStreak() { + let results = AchievementEvaluator.evaluate(days: [ + input(date(2026, 3, 1), keys: 1), + input(date(2026, 3, 2)), + input(date(2026, 3, 3), keys: 1), + ], metersPerPixel: 0, calendar: calendar) + XCTAssertEqual(progress("streak_7", in: results).current, 1, accuracy: 0.001) + } + + func testMarathonUsesMetersPerPixel() { + // 42,195 米 = 42,195,000 像素 × 0.001 米/像素 + let results = AchievementEvaluator.evaluate(days: [ + input(date(2026, 5, 1), keys: 1, distance: 20_000_000), + input(date(2026, 5, 2), keys: 1, distance: 22_195_000), + ], metersPerPixel: 0.001, calendar: calendar) + let marathon = progress("marathon", in: results) + XCTAssertEqual(marathon.unlockedOn, date(2026, 5, 2)) + XCTAssertEqual(marathon.current, 42.195, accuracy: 0.0001) + } + + func testPeakKPSThresholds() { + let results = AchievementEvaluator.evaluate(days: [ + input(date(2026, 6, 1), keys: 1, kps: 9), + input(date(2026, 6, 2), keys: 1, kps: 12), + ], metersPerPixel: 0, calendar: calendar) + XCTAssertEqual(progress("kps_10", in: results).unlockedOn, date(2026, 6, 2)) + XCTAssertFalse(progress("kps_15", in: results).isUnlocked) + XCTAssertEqual(progress("kps_15", in: results).current, 12, accuracy: 0.001) + } + + func testCurrentStreakStartsFromYesterdayWhenTodayInactive() { + let days = [ + input(date(2026, 7, 1), keys: 1), + input(date(2026, 7, 2), keys: 1), + input(date(2026, 7, 3)), + ] + XCTAssertEqual(AchievementEvaluator.currentStreak(days: days, today: date(2026, 7, 3, hour: 9), calendar: calendar), 2) + } + + func testCatalogHasSixteenUniqueAchievements() { + XCTAssertEqual(AchievementCatalog.all.count, 16) + XCTAssertEqual(Set(AchievementCatalog.all.map(\.id)).count, 16) + } +} diff --git a/KeyStatsTests/YearReviewModelsTests.swift b/KeyStatsTests/YearReviewModelsTests.swift new file mode 100644 index 0000000..46fe239 --- /dev/null +++ b/KeyStatsTests/YearReviewModelsTests.swift @@ -0,0 +1,180 @@ +import XCTest +@testable import KeyStatsCore + +final class YearReviewModelsTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + private func analyze(_ counts: [String: Int], keys: Int = 1000, clicks: Int = 300) -> KeyboardPersonaResult { + KeyboardPersonaAnalyzer.analyze(keyCounts: counts, totalKeyPresses: keys, totalClicks: clicks) + } + + // MARK: - Keyboard persona + + func testNotEnoughDataBelowMinimumKeyPresses() { + let result = analyze(["Cmd+C": 100], keys: 199) + XCTAssertNil(result.primary) + XCTAssertEqual(result.secondary, []) + XCTAssertFalse(result.hasEnoughData) + } + + func testShortcutMasterAtExactThresholdWithNormalizedModifiers() { + let result = analyze(["LeftCmd+C": 60, "Cmd+V": 60]) + XCTAssertEqual(result.shortcutCount, 120) + XCTAssertEqual(result.copyCount, 60) + XCTAssertEqual(result.pasteCount, 60) + XCTAssertEqual(result.primary, .shortcutMaster) + XCTAssertEqual(result.secondary, []) + } + + func testShortcutJustBelowThresholdFallsBackToBalanced() { + let result = analyze(["Cmd+S": 119]) + XCTAssertEqual(result.primary, .balancedPlayer) + } + + func testShiftOnlyCombosAreNotShortcuts() { + let result = analyze(["Shift+A": 500]) + XCTAssertEqual(result.shortcutCount, 0) + } + + func testCopyPasteEngineerNeedsThirtyPastesAndTwiceTheCopies() { + XCTAssertEqual(analyze(["Cmd+V": 30, "Cmd+C": 15]).primary, .copyPasteEngineer) + XCTAssertEqual(analyze(["Cmd+V": 29, "Cmd+C": 10]).primary, .balancedPlayer) + XCTAssertEqual(analyze(["Cmd+V": 40, "Cmd+C": 21]).primary, .balancedPlayer) + } + + func testCopyRequiresExactlyCommandModifier() { + let result = analyze(["Cmd+Shift+C": 10, "Ctrl+C": 10, "RightCmd+C": 5]) + XCTAssertEqual(result.copyCount, 5) + } + + func testPerfectionistCountsDeleteAndForwardDeleteWithModifiers() { + let result = analyze(["Delete": 60, "Shift+ForwardDelete": 40]) + XCTAssertEqual(result.regretCount, 100) + XCTAssertEqual(result.primary, .perfectionist) + } + + func testArrowKeyWalkerAtThreshold() { + let result = analyze(["Up": 40, "Shift+Down": 40]) + XCTAssertEqual(result.arrowCount, 80) + XCTAssertEqual(result.primary, .arrowKeyWalker) + } + + func testMousePersonAndKeyboardPuristThresholds() { + XCTAssertEqual(analyze([:], keys: 1000, clicks: 1000).primary, .mousePerson) + XCTAssertEqual(analyze([:], keys: 1000, clicks: 150).primary, .keyboardPurist) + XCTAssertEqual(analyze([:], keys: 1000, clicks: 151).primary, .balancedPlayer) + } + + func testPriorityOrderAndSecondaryTags() { + let result = analyze(["Cmd+K": 150, "Delete": 120], keys: 1000, clicks: 100) + XCTAssertEqual(result.primary, .shortcutMaster) + XCTAssertEqual(result.secondary, [.perfectionist, .keyboardPurist]) + } + + func testTopKeysExcludeSpaceAndMergeLeftRightModifiers() { + let result = analyze(["Space": 900, "LeftCmd+C": 5, "Cmd+C": 5, "E": 8, "A": 8, "Z": 1]) + XCTAssertEqual(result.topKeys, [ + KeyboardPersonaKeyCount(name: "Cmd+C", count: 10), + KeyboardPersonaKeyCount(name: "A", count: 8), + KeyboardPersonaKeyCount(name: "E", count: 8), + ]) + } + + func testParseKeyNameHandlesLiteralPlus() { + let parsed = KeyboardPersonaAnalyzer.parseKeyName("Cmd++") + XCTAssertEqual(parsed.modifiers, ["Cmd"]) + XCTAssertEqual(parsed.base, "+") + XCTAssertEqual(KeyboardPersonaAnalyzer.parseKeyName("+").base, "+") + } + + // MARK: - Contribution wall + + func testLastTwelveMonthsGridStartsOnMondayAndEndsToday() { + let today = date(2026, 10, 22) // 周四 + let model = ContributionWallBuilder.build(range: .last12Months, today: today, calendar: calendar) { _ in 0 } + XCTAssertEqual(model.columnCount, 53) + XCTAssertEqual(model.days.first?.date, date(2025, 10, 20)) + XCTAssertEqual(model.days.first?.row, 0) + XCTAssertEqual(model.days.count, 368) + XCTAssertEqual(model.days.last?.column, 52) + XCTAssertEqual(model.days.last?.row, 3) + XCTAssertEqual(model.monthLabels.prefix(2), [ + ContributionWallMonthLabel(column: 0, month: 10), + ContributionWallMonthLabel(column: 2, month: 11), + ]) + XCTAssertEqual(model.currentStreak, 0) + XCTAssertNil(model.bestDay) + } + + func testPastYearViewHasNoCurrentStreak() { + let today = date(2026, 10, 22) + let model = ContributionWallBuilder.build(range: .year(2025), today: today, calendar: calendar) { _ in 1 } + XCTAssertEqual(model.columnCount, 53) + XCTAssertEqual(model.days.count, 365) + XCTAssertEqual(model.days.first?.date, date(2025, 1, 1)) + XCTAssertEqual(model.days.first?.column, 0) + XCTAssertEqual(model.days.first?.row, 2) // 周三 + XCTAssertNil(model.currentStreak) + XCTAssertEqual(model.longestStreak, 365) + XCTAssertEqual(model.activeDays, 365) + } + + func testCurrentYearStopsAtToday() { + let today = date(2026, 10, 22) + let model = ContributionWallBuilder.build(range: .year(2026), today: today, calendar: calendar) { _ in 0 } + XCTAssertEqual(model.days.last?.date, today) + XCTAssertEqual(model.days.count, 295) + } + + func testCurrentStreakStartsFromYesterdayWhenTodayIsEmpty() { + let today = date(2026, 10, 22) + let active: Set = [date(2026, 10, 19), date(2026, 10, 20), date(2026, 10, 21)] + let model = ContributionWallBuilder.build(range: .last12Months, today: today, calendar: calendar) { + active.contains($0) ? 5 : 0 + } + XCTAssertEqual(model.currentStreak, 3) + XCTAssertEqual(model.longestStreak, 3) + + let withToday = active.union([today]) + let streak = ContributionWallBuilder.streakEnding(at: today, calendar: calendar) { withToday.contains($0) ? 1 : 0 } + XCTAssertEqual(streak, 4) + } + + func testQuartileBucketsAndSummary() { + let today = date(2026, 10, 22) + // 最近 8 天依次为 1...8 + let model = ContributionWallBuilder.build(range: .last12Months, today: today, calendar: calendar) { day in + let offset = self.calendar.dateComponents([.day], from: day, to: today).day ?? 99 + return offset < 8 ? 8 - offset : 0 + } + XCTAssertEqual(model.quartiles.q1, 2) + XCTAssertEqual(model.quartiles.q2, 4) + XCTAssertEqual(model.quartiles.q3, 6) + XCTAssertEqual(model.intensity(for: 0), 0) + XCTAssertEqual(model.intensity(for: 2), 0.25) + XCTAssertEqual(model.intensity(for: 3), 0.5) + XCTAssertEqual(model.intensity(for: 6), 0.75) + XCTAssertEqual(model.intensity(for: 7), 1.0) + XCTAssertEqual(model.total, 36) + XCTAssertEqual(model.activeDays, 8) + XCTAssertEqual(model.bestDay, ContributionWallBestDay(date: today, value: 8)) + XCTAssertEqual(model.currentStreak, 8) + } + + func testAvailableRangesListYearsNewestFirst() { + let ranges = ContributionWallBuilder.availableRanges(earliestDate: date(2024, 5, 1), + today: date(2026, 10, 22), + calendar: calendar) + XCTAssertEqual(ranges, [.last12Months, .year(2026), .year(2025), .year(2024)]) + XCTAssertEqual(ContributionWallBuilder.availableRanges(earliestDate: nil, today: date(2026, 10, 22), calendar: calendar), + [.last12Months, .year(2026)]) + } +} diff --git a/Package.swift b/Package.swift index 16d2056..810da82 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,9 @@ let package = Package( "KeyboardHeatmapWindowController.swift", "StatsPopoverViewController.swift", "SettingsWindowController.swift", - "MainWindowViewController.swift" + "MainWindowViewController.swift", + "YearReviewWindowController.swift", + "AchievementManager.swift" ], sources: [ "AppStats.swift", @@ -61,14 +63,15 @@ let package = Package( "Sync/SyncCrypto.swift", "Sync/SyncStorage.swift", "Sync/SyncTransport.swift", - "Sync/DisplayStatsAggregator.swift" + "Sync/DisplayStatsAggregator.swift", + "YearReviewModels.swift" ] ), .testTarget( name: "KeyStatsCoreTests", dependencies: ["KeyStatsCore"], path: "KeyStatsTests", - sources: ["HourlyStatsTests.swift", "AppStatsTests.swift", "AppActivityTrackerTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift"] + sources: ["HourlyStatsTests.swift", "AppStatsTests.swift", "AppActivityTrackerTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift", "YearReviewModelsTests.swift", "ActivityInsightsTests.swift"] ), .target( name: "KeyStatsHelperLogic",