From c5c6e3ff32639ed35b7b8affb753e3d46b36afb5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Thu, 22 Oct 2026 15:44:55 +0800
Subject: [PATCH 1/9] feat(heatmap): add heatmap color schemes on Windows
Add a heatmap color setting with 7 presets (default blue, warm, spectrum,
green, teal, purple, rose) plus a custom low/high color picked from the
system color dialog. The settings card shows a live palette preview and
open heatmap windows repaint immediately. Default palette is unchanged.
---
.../KeyStats/Helpers/HeatmapPalette.cs | 177 ++++++++++++++++++
.../KeyStats/Models/AppSettings.cs | 17 ++
.../KeyStats/Properties/Strings.cs | 14 ++
.../KeyStats/Properties/Strings.resx | 14 ++
.../KeyStats/Properties/Strings.zh-Hans.resx | 14 ++
.../KeyStats/Properties/Strings.zh-Hant.resx | 14 ++
.../Controls/HeatmapPalettePreviewControl.cs | 76 ++++++++
.../Views/Controls/KeyboardHeatmapControl.cs | 24 +--
.../KeyStats/Views/SettingsWindow.xaml | 74 ++++++++
.../KeyStats/Views/SettingsWindow.xaml.cs | 146 ++++++++++++++-
10 files changed, 549 insertions(+), 21 deletions(-)
create mode 100644 KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
create mode 100644 KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs
diff --git a/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
new file mode 100644
index 00000000..42a090e4
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Media;
+using KeyStats.Models;
+using KeyStats.Services;
+
+namespace KeyStats.Helpers;
+
+///
+/// 键盘热力图配色:预置色板 + 用户自定义起止色。热力图控件和设置页预览共用同一套取色逻辑。
+///
+public static class HeatmapPalette
+{
+ private sealed class Preset
+ {
+ public string Id { get; }
+ public Color[] LightStops { get; }
+ public Color[] DarkStops { get; }
+
+ public Preset(string id, Color[] lightStops, Color[] darkStops)
+ {
+ Id = id;
+ LightStops = lightStops;
+ DarkStops = darkStops;
+ }
+ }
+
+ // 每个预置色板三个色阶:低频 → 中频 → 高频。深色模式下高频键更亮,浅色模式下更深。
+ private static readonly IReadOnlyList Presets = new[]
+ {
+ new Preset(AppSettings.HeatmapColorSchemeWarm,
+ new[] { Rgb(255, 196, 92), Rgb(251, 140, 60), Rgb(229, 57, 53) },
+ new[] { Rgb(196, 132, 40), Rgb(226, 110, 38), Rgb(222, 64, 56) }),
+ new Preset(AppSettings.HeatmapColorSchemeSpectrum,
+ new[] { Rgb(100, 181, 246), Rgb(255, 202, 40), Rgb(229, 57, 53) },
+ new[] { Rgb(30, 136, 229), Rgb(253, 216, 53), Rgb(239, 83, 80) }),
+ new Preset(AppSettings.HeatmapColorSchemeGreen,
+ new[] { Rgb(129, 199, 132), Rgb(67, 160, 71), Rgb(27, 94, 32) },
+ new[] { Rgb(46, 125, 50), Rgb(67, 160, 71), Rgb(129, 199, 132) }),
+ new Preset(AppSettings.HeatmapColorSchemeTeal,
+ new[] { Rgb(128, 222, 234), Rgb(0, 172, 193), Rgb(0, 96, 100) },
+ new[] { Rgb(0, 96, 100), Rgb(0, 151, 167), Rgb(77, 208, 225) }),
+ new Preset(AppSettings.HeatmapColorSchemePurple,
+ new[] { Rgb(206, 147, 216), Rgb(142, 36, 170), Rgb(74, 20, 140) },
+ new[] { Rgb(106, 27, 154), Rgb(156, 39, 176), Rgb(206, 147, 216) }),
+ new Preset(AppSettings.HeatmapColorSchemeRose,
+ new[] { Rgb(248, 187, 208), Rgb(236, 64, 122), Rgb(173, 20, 87) },
+ new[] { Rgb(136, 14, 79), Rgb(216, 27, 96), Rgb(244, 143, 177) }),
+ };
+
+ public static readonly Color DefaultCustomLowColor = Rgb(255, 224, 130);
+ public static readonly Color DefaultCustomHighColor = Rgb(198, 40, 40);
+
+ public static event Action? SchemeChanged;
+
+ public static void NotifySchemeChanged()
+ {
+ SchemeChanged?.Invoke();
+ }
+
+ /// 按键次数为 0 时的底色。
+ public static Color NeutralColor(bool isDarkMode)
+ {
+ var surface = ResolveColor("SurfaceBrush", isDarkMode ? Color.FromRgb(32, 32, 32) : Color.FromRgb(250, 250, 250));
+ var divider = ResolveColor("DividerBrush", isDarkMode ? Color.FromRgb(61, 61, 61) : Color.FromRgb(229, 229, 229));
+ return Blend(surface, divider, isDarkMode ? 0.22 : 0.10);
+ }
+
+ /// 按当前设置取色,intensity 为 0~1 的热度。
+ public static Color ColorFor(double intensity, bool isDarkMode)
+ {
+ var settings = StatsManager.Instance.Settings;
+ return ColorFor(
+ settings.HeatmapColorScheme,
+ ParseColor(settings.HeatmapCustomLowColor, DefaultCustomLowColor),
+ ParseColor(settings.HeatmapCustomHighColor, DefaultCustomHighColor),
+ intensity,
+ isDarkMode);
+ }
+
+ public static Color ColorFor(string? scheme, Color customLow, Color customHigh, double intensity, bool isDarkMode)
+ {
+ var neutral = NeutralColor(isDarkMode);
+
+ if (string.Equals(scheme, AppSettings.HeatmapColorSchemeCustom, StringComparison.Ordinal))
+ {
+ return ThreeStopColor(neutral, customLow, Blend(customLow, customHigh, 0.5), customHigh, intensity, isDarkMode);
+ }
+
+ var preset = Presets.FirstOrDefault(p => string.Equals(p.Id, scheme, StringComparison.Ordinal));
+ if (preset != null)
+ {
+ var stops = isDarkMode ? preset.DarkStops : preset.LightStops;
+ return ThreeStopColor(neutral, stops[0], stops[1], stops[2], intensity, isDarkMode);
+ }
+
+ // 默认:跟随系统强调色(保持原有配色不变)
+ var accent = ResolveColor("AccentBrush", isDarkMode ? Color.FromRgb(0, 120, 212) : Color.FromRgb(0, 103, 192));
+ var low = Blend(neutral, accent, isDarkMode ? 0.34 : 0.24);
+ var high = Blend(accent, isDarkMode ? Colors.White : Colors.Black, isDarkMode ? 0.18 : 0.10);
+ return Blend(low, high, intensity);
+ }
+
+ /// 设置页下拉项里的色条缩略图。
+ public static Brush CreatePreviewBrush(string scheme, Color customLow, Color customHigh, bool isDarkMode)
+ {
+ var brush = new LinearGradientBrush { StartPoint = new Point(0, 0.5), EndPoint = new Point(1, 0.5) };
+ const int steps = 6;
+ for (var i = 0; i <= steps; i++)
+ {
+ var t = i / (double)steps;
+ brush.GradientStops.Add(new GradientStop(ColorFor(scheme, customLow, customHigh, Math.Max(0.05, t), isDarkMode), t));
+ }
+ brush.Freeze();
+ return brush;
+ }
+
+ public static Color ParseColor(string? hex, Color fallback)
+ {
+ if (string.IsNullOrWhiteSpace(hex))
+ {
+ return fallback;
+ }
+
+ try
+ {
+ return ColorConverter.ConvertFromString(hex) is Color color ? color : fallback;
+ }
+ catch (FormatException)
+ {
+ return fallback;
+ }
+ }
+
+ public static string ToHex(Color color)
+ {
+ return string.Format(CultureInfo.InvariantCulture, "#{0:X2}{1:X2}{2:X2}", color.R, color.G, color.B);
+ }
+
+ public static Color Blend(Color from, Color to, double t)
+ {
+ var progress = Math.Max(0, Math.Min(1, t));
+ return Color.FromArgb(
+ (byte)Math.Round(from.A + (to.A - from.A) * progress),
+ (byte)Math.Round(from.R + (to.R - from.R) * progress),
+ (byte)Math.Round(from.G + (to.G - from.G) * progress),
+ (byte)Math.Round(from.B + (to.B - from.B) * progress));
+ }
+
+ public static Color ResolveColor(string key, Color fallback)
+ {
+ var resource = Application.Current?.Resources[key];
+ if (resource is SolidColorBrush brush)
+ {
+ return brush.Color;
+ }
+ if (resource is Color color)
+ {
+ return color;
+ }
+ return fallback;
+ }
+
+ private static Color ThreeStopColor(Color neutral, Color lowStop, Color midStop, Color highStop, double intensity, bool isDarkMode)
+ {
+ // 低频键与底色混合,避免只按过一两次的键过于抢眼
+ var low = Blend(neutral, lowStop, isDarkMode ? 0.45 : 0.40);
+ return intensity < 0.5
+ ? Blend(low, midStop, intensity / 0.5)
+ : Blend(midStop, highStop, (intensity - 0.5) / 0.5);
+ }
+
+ private static Color Rgb(byte r, byte g, byte b) => Color.FromRgb(r, g, b);
+}
diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs
index d58f5e2f..4319165e 100644
--- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs
+++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs
@@ -12,6 +12,14 @@ public class AppSettings
public const int DefaultFloatingStatsFontSize = 12;
public const int MinimumFloatingStatsFontSize = 9;
public const int MaximumFloatingStatsFontSize = 22;
+ public const string HeatmapColorSchemeDefault = "default";
+ public const string HeatmapColorSchemeWarm = "warm";
+ public const string HeatmapColorSchemeSpectrum = "spectrum";
+ public const string HeatmapColorSchemeGreen = "green";
+ public const string HeatmapColorSchemeTeal = "teal";
+ public const string HeatmapColorSchemePurple = "purple";
+ public const string HeatmapColorSchemeRose = "rose";
+ public const string HeatmapColorSchemeCustom = "custom";
private int _floatingStatsFontSize = DefaultFloatingStatsFontSize;
[JsonPropertyName("notificationsEnabled")]
@@ -101,6 +109,15 @@ public int FloatingStatsFontSize
[JsonPropertyName("floatingStatsPositionLocked")]
public bool FloatingStatsPositionLocked { get; set; }
+ [JsonPropertyName("heatmapColorScheme")]
+ public string HeatmapColorScheme { get; set; } = HeatmapColorSchemeDefault; // "default" | "warm" | "spectrum" | "green" | "teal" | "purple" | "rose" | "custom"
+
+ [JsonPropertyName("heatmapCustomLowColor")]
+ public string HeatmapCustomLowColor { get; set; } = "#FFE082";
+
+ [JsonPropertyName("heatmapCustomHighColor")]
+ public string HeatmapCustomHighColor { get; set; } = "#C62828";
+
[JsonPropertyName("languagePreference")]
public string LanguagePreference { get; set; } = "system"; // "system" | "zh-Hans" | "zh-Hant" | "en"
}
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs
index 9de5a5aa..b359298b 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.cs
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs
@@ -52,6 +52,20 @@ public static class Strings
public static string Dialog_ImportTitle => Get(nameof(Dialog_ImportTitle));
public static string Dialog_JsonFilter => Get(nameof(Dialog_JsonFilter));
+ public static string Settings_HeatmapColorScheme => Get(nameof(Settings_HeatmapColorScheme));
+ public static string Settings_HeatmapColorSchemeDesc => Get(nameof(Settings_HeatmapColorSchemeDesc));
+ public static string Settings_HeatmapColorScheme_Default => Get(nameof(Settings_HeatmapColorScheme_Default));
+ public static string Settings_HeatmapColorScheme_Warm => Get(nameof(Settings_HeatmapColorScheme_Warm));
+ public static string Settings_HeatmapColorScheme_Spectrum => Get(nameof(Settings_HeatmapColorScheme_Spectrum));
+ public static string Settings_HeatmapColorScheme_Green => Get(nameof(Settings_HeatmapColorScheme_Green));
+ public static string Settings_HeatmapColorScheme_Teal => Get(nameof(Settings_HeatmapColorScheme_Teal));
+ public static string Settings_HeatmapColorScheme_Purple => Get(nameof(Settings_HeatmapColorScheme_Purple));
+ public static string Settings_HeatmapColorScheme_Rose => Get(nameof(Settings_HeatmapColorScheme_Rose));
+ public static string Settings_HeatmapColorScheme_Custom => Get(nameof(Settings_HeatmapColorScheme_Custom));
+ public static string Settings_HeatmapCustomLow => Get(nameof(Settings_HeatmapCustomLow));
+ 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 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 b4f5e98e..8a008a54 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx
@@ -109,6 +109,20 @@
Language
Display language for the app interface
System
+ Heatmap Colors
+ Color palette for the keyboard heatmap
+ Default (Blue)
+ Warm (Orange-Red)
+ Spectrum (Blue-Yellow-Red)
+ Green
+ Teal
+ Purple
+ Rose
+ Custom
+ Low color
+ High color
+ Less
+ More
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 0f7a9693..42b4f4e3 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
@@ -109,6 +109,20 @@
语言
应用界面显示语言
跟随系统
+ 热力图配色
+ 键盘热力图的按键颜色
+ 默认(蓝色)
+ 暖色(橙红)
+ 光谱(蓝黄红)
+ 绿色
+ 青色
+ 紫色
+ 玫红
+ 自定义
+ 起始色
+ 最高色
+ 少
+ 多
需要重启
需要重启 KeyStats 才能切换语言。是否立即重启?
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
index d041a3c3..655f800d 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
@@ -109,6 +109,20 @@
語言
應用程式介面的顯示語言
跟隨系統
+ 熱力圖配色
+ 鍵盤熱力圖的按鍵顏色
+ 預設(藍色)
+ 暖色(橙紅)
+ 光譜(藍黃紅)
+ 綠色
+ 青色
+ 紫色
+ 玫紅
+ 自訂
+ 起始色
+ 最高色
+ 少
+ 多
需要重新啟動
KeyStats 需要重新啟動才能套用新的語言。要立即重新啟動嗎?
diff --git a/KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs
new file mode 100644
index 00000000..a4424e2c
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Windows;
+using System.Windows.Media;
+using KeyStats.Helpers;
+
+namespace KeyStats.Views.Controls;
+
+///
+/// 设置页里的热力图色阶预览:从「未按过」到最高热度的一排小键帽。
+///
+public class HeatmapPalettePreviewControl : FrameworkElement
+{
+ private const int CellCount = 9;
+ private const double CellGap = 4;
+
+ public HeatmapPalettePreviewControl()
+ {
+ SnapsToDevicePixels = true;
+ UseLayoutRounding = true;
+ Height = 22;
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ protected override void OnRender(DrawingContext drawingContext)
+ {
+ base.OnRender(drawingContext);
+
+ if (ActualWidth <= 0 || ActualHeight <= 0)
+ {
+ return;
+ }
+
+ var isDarkMode = ThemeManager.Instance.IsDarkTheme;
+ var borderColor = isDarkMode
+ ? Color.FromArgb(36, 255, 255, 255)
+ : Color.FromArgb(23, 0, 0, 0);
+ var borderPen = new Pen(new SolidColorBrush(borderColor), 0.8);
+ borderPen.Freeze();
+
+ var cellWidth = (ActualWidth - CellGap * (CellCount - 1)) / CellCount;
+ if (cellWidth <= 0)
+ {
+ return;
+ }
+
+ var radius = Math.Min(4, ActualHeight * 0.2);
+ for (var i = 0; i < CellCount; i++)
+ {
+ var color = i == 0
+ ? HeatmapPalette.NeutralColor(isDarkMode)
+ : HeatmapPalette.ColorFor(i / (double)(CellCount - 1), isDarkMode);
+ var brush = new SolidColorBrush(color);
+ brush.Freeze();
+ var rect = new Rect(i * (cellWidth + CellGap), 0, cellWidth, ActualHeight);
+ drawingContext.DrawRoundedRectangle(brush, borderPen, rect, radius, radius);
+ }
+ }
+
+ 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;
+ }
+
+ private void OnPaletteChanged()
+ {
+ Dispatcher.BeginInvoke(new Action(InvalidateVisual));
+ }
+}
diff --git a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
index 62a5c0f0..91079cc6 100644
--- a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
+++ b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
@@ -99,11 +99,13 @@ protected override void OnRender(DrawingContext drawingContext)
private void OnLoaded(object sender, RoutedEventArgs e)
{
ThemeManager.Instance.ThemeChanged += OnThemeChanged;
+ HeatmapPalette.SchemeChanged += OnThemeChanged;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
ThemeManager.Instance.ThemeChanged -= OnThemeChanged;
+ HeatmapPalette.SchemeChanged -= OnThemeChanged;
}
private void OnThemeChanged()
@@ -334,13 +336,9 @@ private static string CompactUnit(double value, string suffix)
private Color ColorForCount(int count, bool isDarkMode)
{
- var surface = ResolveColor("SurfaceBrush", isDarkMode ? Color.FromRgb(32, 32, 32) : Color.FromRgb(250, 250, 250));
- var divider = ResolveColor("DividerBrush", isDarkMode ? Color.FromRgb(61, 61, 61) : Color.FromRgb(229, 229, 229));
- var neutral = Blend(surface, divider, isDarkMode ? 0.22 : 0.10);
-
if (count <= 0)
{
- return neutral;
+ return HeatmapPalette.NeutralColor(isDarkMode);
}
var normalized = _maxCount > 0
@@ -348,11 +346,7 @@ private Color ColorForCount(int count, bool isDarkMode)
: 0;
var eased = Math.Pow(normalized, 0.82);
- var accent = ResolveColor("AccentBrush", isDarkMode ? Color.FromRgb(0, 120, 212) : Color.FromRgb(0, 103, 192));
- var low = Blend(neutral, accent, isDarkMode ? 0.34 : 0.24);
- var high = Blend(accent, isDarkMode ? Colors.White : Colors.Black, isDarkMode ? 0.18 : 0.10);
-
- return Blend(low, high, eased);
+ return HeatmapPalette.ColorFor(eased, isDarkMode);
}
private static Color ResolveColor(string key, Color fallback)
@@ -369,16 +363,6 @@ private static Color ResolveColor(string key, Color fallback)
return fallback;
}
- private static Color Blend(Color from, Color to, double t)
- {
- var progress = Math.Max(0, Math.Min(1, t));
- return Color.FromArgb(
- (byte)Math.Round(from.A + (to.A - from.A) * progress),
- (byte)Math.Round(from.R + (to.R - from.R) * progress),
- (byte)Math.Round(from.G + (to.G - from.G) * progress),
- (byte)Math.Round(from.B + (to.B - from.B) * progress));
- }
-
private static SolidColorBrush CreateBrush(Color color)
{
var brush = new SolidColorBrush(color);
diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml
index 3c28d7cc..ee4ac008 100644
--- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml
+++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:p="clr-namespace:KeyStats.Properties"
+ xmlns:controls="clr-namespace:KeyStats.Views.Controls"
Title="{x:Static p:Strings.Settings_WindowTitle}"
Width="420" SizeToContent="Height"
WindowStartupLocation="CenterScreen"
@@ -206,6 +207,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs
index c2f42452..ed2a64db 100644
--- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs
+++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs
@@ -35,6 +35,7 @@ private void OnLoaded(object sender, RoutedEventArgs e)
UpdateMaximumHeight();
ApplyWindowBackdrop();
LoadFloatingStatsControls();
+ LoadHeatmapColorSchemeControls();
if (App.CurrentApp?.SyncCoordinator != null)
{
App.CurrentApp.SyncCoordinator.StatusChanged += OnSyncStatusChanged;
@@ -55,7 +56,12 @@ private void OnClosed(object? sender, System.EventArgs e)
private void OnThemeChanged()
{
- Dispatcher.BeginInvoke(new System.Action(ApplyWindowBackdrop));
+ Dispatcher.BeginInvoke(new System.Action(() =>
+ {
+ ApplyWindowBackdrop();
+ // 下拉项色条按深浅色主题分别生成,主题切换后重建
+ LoadHeatmapColorSchemeControls();
+ }));
}
private void ApplyWindowBackdrop()
@@ -344,6 +350,118 @@ private void OpenGitHub_Click(object sender, RoutedEventArgs e)
}
}
+ private bool _isLoadingHeatmapColorScheme = true;
+
+ private void LoadHeatmapColorSchemeControls()
+ {
+ _isLoadingHeatmapColorScheme = true;
+ var settings = StatsManager.Instance.Settings;
+ var isDarkMode = ThemeManager.Instance.IsDarkTheme;
+ var customLow = HeatmapPalette.ParseColor(settings.HeatmapCustomLowColor, HeatmapPalette.DefaultCustomLowColor);
+ var customHigh = HeatmapPalette.ParseColor(settings.HeatmapCustomHighColor, HeatmapPalette.DefaultCustomHighColor);
+
+ HeatmapColorSchemeComboBox.ItemsSource = new[]
+ {
+ (AppSettings.HeatmapColorSchemeDefault, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Default),
+ (AppSettings.HeatmapColorSchemeWarm, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Warm),
+ (AppSettings.HeatmapColorSchemeSpectrum, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Spectrum),
+ (AppSettings.HeatmapColorSchemeGreen, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Green),
+ (AppSettings.HeatmapColorSchemeTeal, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Teal),
+ (AppSettings.HeatmapColorSchemePurple, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Purple),
+ (AppSettings.HeatmapColorSchemeRose, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Rose),
+ (AppSettings.HeatmapColorSchemeCustom, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Custom),
+ }
+ .Select(option => new HeatmapSchemeOption(
+ option.Item1,
+ option.Item2,
+ HeatmapPalette.CreatePreviewBrush(option.Item1, customLow, customHigh, isDarkMode)))
+ .ToList();
+ HeatmapColorSchemeComboBox.SelectedValue = settings.HeatmapColorScheme;
+ if (HeatmapColorSchemeComboBox.SelectedIndex < 0)
+ {
+ HeatmapColorSchemeComboBox.SelectedIndex = 0;
+ }
+
+ HeatmapCustomLowSwatch.Background = new SolidColorBrush(customLow);
+ HeatmapCustomHighSwatch.Background = new SolidColorBrush(customHigh);
+ HeatmapCustomColorsPanel.Visibility = settings.HeatmapColorScheme == AppSettings.HeatmapColorSchemeCustom
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ _isLoadingHeatmapColorScheme = false;
+ }
+
+ private void HeatmapColorSchemeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_isLoadingHeatmapColorScheme || HeatmapColorSchemeComboBox.SelectedValue is not string scheme)
+ {
+ return;
+ }
+
+ var settings = StatsManager.Instance.Settings;
+ if (scheme == settings.HeatmapColorScheme)
+ {
+ return;
+ }
+
+ settings.HeatmapColorScheme = scheme;
+ StatsManager.Instance.SaveSettings();
+ HeatmapCustomColorsPanel.Visibility = scheme == AppSettings.HeatmapColorSchemeCustom
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ HeatmapPalette.NotifySchemeChanged();
+ App.CurrentApp?.TrackClick("settings_heatmap_color_scheme", new System.Collections.Generic.Dictionary
+ {
+ ["scheme"] = scheme
+ });
+ }
+
+ private void HeatmapCustomLowColor_Click(object sender, RoutedEventArgs e)
+ {
+ PickHeatmapCustomColor(isLow: true);
+ }
+
+ private void HeatmapCustomHighColor_Click(object sender, RoutedEventArgs e)
+ {
+ PickHeatmapCustomColor(isLow: false);
+ }
+
+ private void PickHeatmapCustomColor(bool isLow)
+ {
+ var settings = StatsManager.Instance.Settings;
+ var current = isLow
+ ? HeatmapPalette.ParseColor(settings.HeatmapCustomLowColor, HeatmapPalette.DefaultCustomLowColor)
+ : HeatmapPalette.ParseColor(settings.HeatmapCustomHighColor, HeatmapPalette.DefaultCustomHighColor);
+
+ using var dialog = new Forms.ColorDialog
+ {
+ AnyColor = true,
+ FullOpen = true,
+ Color = System.Drawing.Color.FromArgb(current.R, current.G, current.B)
+ };
+ if (dialog.ShowDialog(new Win32WindowOwner(new WindowInteropHelper(this).Handle)) != Forms.DialogResult.OK)
+ {
+ return;
+ }
+
+ var hex = HeatmapPalette.ToHex(Color.FromRgb(dialog.Color.R, dialog.Color.G, dialog.Color.B));
+ if (isLow)
+ {
+ settings.HeatmapCustomLowColor = hex;
+ }
+ else
+ {
+ settings.HeatmapCustomHighColor = hex;
+ }
+ StatsManager.Instance.SaveSettings();
+ // 刷新下拉项里「自定义」的色条和色块
+ LoadHeatmapColorSchemeControls();
+ HeatmapPalette.NotifySchemeChanged();
+ App.CurrentApp?.TrackClick("settings_heatmap_custom_color", new System.Collections.Generic.Dictionary
+ {
+ ["slot"] = isLow ? "low" : "high"
+ });
+ }
+
private bool _isInitializingLanguage = true;
private void LanguageComboBox_Loaded(object sender, RoutedEventArgs e)
@@ -417,6 +535,32 @@ private static void RestartApp()
Application.Current.Shutdown();
}
+ private sealed class HeatmapSchemeOption
+ {
+ public HeatmapSchemeOption(string id, string label, Brush previewBrush)
+ {
+ Id = id;
+ Label = label;
+ PreviewBrush = previewBrush;
+ }
+
+ public string Id { get; }
+
+ public string Label { get; }
+
+ public Brush PreviewBrush { get; }
+ }
+
+ private sealed class Win32WindowOwner : Forms.IWin32Window
+ {
+ public Win32WindowOwner(System.IntPtr handle)
+ {
+ Handle = handle;
+ }
+
+ public System.IntPtr Handle { get; }
+ }
+
private sealed class FloatingMetricOption
{
public FloatingMetricOption(string id, string label)
From 9d30c91a9b248bd448ba2e23e52d6c4244144fda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Thu, 22 Oct 2026 15:44:55 +0800
Subject: [PATCH 2/9] feat(heatmap): add heatmap color schemes on macOS
Mirror the Windows heatmap color setting: same presets and gradient math,
custom low/high colors via NSColorWell, palette preview in settings, and
live repaint of open heatmap views. Default palette is unchanged.
---
KeyStats/KeyboardHeatmapViewController.swift | 302 ++++++++++++++++++-
KeyStats/SettingsViewController.swift | 105 +++++++
KeyStats/en.lproj/Localizable.strings | 16 +
KeyStats/zh-Hans.lproj/Localizable.strings | 16 +
KeyStats/zh-Hant.lproj/Localizable.strings | 16 +
5 files changed, 441 insertions(+), 14 deletions(-)
diff --git a/KeyStats/KeyboardHeatmapViewController.swift b/KeyStats/KeyboardHeatmapViewController.swift
index 689d9c18..1a7ad336 100644
--- a/KeyStats/KeyboardHeatmapViewController.swift
+++ b/KeyStats/KeyboardHeatmapViewController.swift
@@ -994,10 +994,30 @@ private final class KeyboardHeatmapView: NSView {
return formatter
}()
+ private var colorSchemeObserver: NSObjectProtocol?
+
override var isFlipped: Bool {
true
}
+ deinit {
+ if let observer = colorSchemeObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard window != nil, colorSchemeObserver == nil else { return }
+ colorSchemeObserver = NotificationCenter.default.addObserver(
+ forName: .heatmapColorSchemeDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.needsDisplay = true
+ }
+ }
+
func apply(keyCounts: [String: Int]) {
self.keyCounts = keyCounts
self.maxCount = keyCounts.values.max() ?? 0
@@ -1296,14 +1316,8 @@ private final class KeyboardHeatmapView: NSView {
}
private func color(for count: Int, isDarkMode: Bool) -> NSColor {
- let surfaceColor = isDarkMode ? NSColor.windowBackgroundColor : NSColor.controlBackgroundColor
- let neutralColor = surfaceColor.blended(withFraction: isDarkMode ? 0.22 : 0.10, of: .separatorColor)
- ?? (isDarkMode
- ? NSColor(calibratedRed: 0.20, green: 0.22, blue: 0.25, alpha: 1)
- : NSColor(calibratedRed: 0.93, green: 0.94, blue: 0.96, alpha: 1))
-
if count <= 0 {
- return neutralColor
+ return HeatmapPalette.neutralColor(isDarkMode: isDarkMode)
}
let normalized = maxCount > 0
@@ -1311,16 +1325,172 @@ private final class KeyboardHeatmapView: NSView {
: 0
let eased = pow(normalized, 0.82)
- let accentColor = NSColor.controlAccentColor
- let lowColor = neutralColor.blended(withFraction: isDarkMode ? 0.34 : 0.24, of: accentColor)
- ?? blendColor(from: neutralColor, to: accentColor, t: isDarkMode ? 0.34 : 0.24)
- let highColor = accentColor.blended(withFraction: isDarkMode ? 0.18 : 0.10, of: isDarkMode ? .white : .black)
- ?? blendColor(from: accentColor, to: isDarkMode ? .white : .black, t: isDarkMode ? 0.18 : 0.10)
+ return HeatmapPalette.color(intensity: eased, isDarkMode: isDarkMode)
+ }
+}
+
+// MARK: - Heatmap Palette
+
+extension Notification.Name {
+ static let heatmapColorSchemeDidChange = Notification.Name("heatmapColorSchemeDidChange")
+}
+
+enum HeatmapColorScheme: String, CaseIterable {
+ case `default`
+ case warm
+ case spectrum
+ case green
+ case teal
+ case purple
+ case rose
+ case custom
+
+ var localizedTitle: String {
+ NSLocalizedString("settings.heatmapColorScheme.\(rawValue)", comment: "")
+ }
+}
+
+/// 键盘热力图配色:预置色板 + 用户自定义起止色。色值与 Windows 端 HeatmapPalette.cs 保持一致。
+enum HeatmapPalette {
+ static let schemeKey = "heatmapColorScheme"
+ static let customLowColorKey = "heatmapCustomLowColor"
+ static let customHighColorKey = "heatmapCustomHighColor"
+ static let defaultCustomLowHex = "#FFE082"
+ static let defaultCustomHighHex = "#C62828"
+
+ private static let presetStops: [HeatmapColorScheme: (light: [NSColor], dark: [NSColor])] = [
+ .warm: (
+ light: [rgb(255, 196, 92), rgb(251, 140, 60), rgb(229, 57, 53)],
+ dark: [rgb(196, 132, 40), rgb(226, 110, 38), rgb(222, 64, 56)]
+ ),
+ .spectrum: (
+ light: [rgb(100, 181, 246), rgb(255, 202, 40), rgb(229, 57, 53)],
+ dark: [rgb(30, 136, 229), rgb(253, 216, 53), rgb(239, 83, 80)]
+ ),
+ .green: (
+ light: [rgb(129, 199, 132), rgb(67, 160, 71), rgb(27, 94, 32)],
+ dark: [rgb(46, 125, 50), rgb(67, 160, 71), rgb(129, 199, 132)]
+ ),
+ .teal: (
+ light: [rgb(128, 222, 234), rgb(0, 172, 193), rgb(0, 96, 100)],
+ dark: [rgb(0, 96, 100), rgb(0, 151, 167), rgb(77, 208, 225)]
+ ),
+ .purple: (
+ light: [rgb(206, 147, 216), rgb(142, 36, 170), rgb(74, 20, 140)],
+ dark: [rgb(106, 27, 154), rgb(156, 39, 176), rgb(206, 147, 216)]
+ ),
+ .rose: (
+ light: [rgb(248, 187, 208), rgb(236, 64, 122), rgb(173, 20, 87)],
+ dark: [rgb(136, 14, 79), rgb(216, 27, 96), rgb(244, 143, 177)]
+ )
+ ]
+
+ // MARK: Settings
+
+ static var scheme: HeatmapColorScheme {
+ get {
+ let rawValue = UserDefaults.standard.string(forKey: schemeKey) ?? HeatmapColorScheme.default.rawValue
+ return HeatmapColorScheme(rawValue: rawValue) ?? .default
+ }
+ set {
+ UserDefaults.standard.set(newValue.rawValue, forKey: schemeKey)
+ notifyChange()
+ }
+ }
+
+ static var customLowColor: NSColor {
+ get { storedColor(forKey: customLowColorKey, fallbackHex: defaultCustomLowHex) }
+ set {
+ UserDefaults.standard.set(hexString(from: newValue), forKey: customLowColorKey)
+ notifyChange()
+ }
+ }
- return blendColor(from: lowColor, to: highColor, t: eased)
+ static var customHighColor: NSColor {
+ get { storedColor(forKey: customHighColorKey, fallbackHex: defaultCustomHighHex) }
+ set {
+ UserDefaults.standard.set(hexString(from: newValue), forKey: customHighColorKey)
+ notifyChange()
+ }
}
- private func blendColor(from: NSColor, to: NSColor, t: CGFloat) -> NSColor {
+ static func notifyChange() {
+ NotificationCenter.default.post(name: .heatmapColorSchemeDidChange, object: nil)
+ }
+
+ // MARK: Colors
+
+ /// 按键次数为 0 时的底色。需在目标 appearance 下调用(如 draw(_:) 内)。
+ static func neutralColor(isDarkMode: Bool) -> NSColor {
+ let surfaceColor = isDarkMode ? NSColor.windowBackgroundColor : NSColor.controlBackgroundColor
+ return surfaceColor.blended(withFraction: isDarkMode ? 0.22 : 0.10, of: .separatorColor)
+ ?? (isDarkMode
+ ? NSColor(calibratedRed: 0.20, green: 0.22, blue: 0.25, alpha: 1)
+ : NSColor(calibratedRed: 0.93, green: 0.94, blue: 0.96, alpha: 1))
+ }
+
+ /// 按当前设置取色,intensity 为 0~1 的热度。
+ static func color(intensity: CGFloat, isDarkMode: Bool) -> NSColor {
+ color(for: scheme, intensity: intensity, isDarkMode: isDarkMode)
+ }
+
+ static func color(for scheme: HeatmapColorScheme, intensity: CGFloat, isDarkMode: Bool) -> NSColor {
+ let neutral = HeatmapPalette.neutralColor(isDarkMode: isDarkMode)
+
+ switch scheme {
+ case .default:
+ // 默认:跟随系统强调色(保持原有配色不变)
+ let accentColor = NSColor.controlAccentColor
+ let lowColor = neutral.blended(withFraction: isDarkMode ? 0.34 : 0.24, of: accentColor)
+ ?? blend(from: neutral, to: accentColor, t: isDarkMode ? 0.34 : 0.24)
+ let highColor = accentColor.blended(withFraction: isDarkMode ? 0.18 : 0.10, of: isDarkMode ? .white : .black)
+ ?? blend(from: accentColor, to: isDarkMode ? .white : .black, t: isDarkMode ? 0.18 : 0.10)
+ return blend(from: lowColor, to: highColor, t: intensity)
+ case .custom:
+ let low = customLowColor
+ let high = customHighColor
+ return threeStopColor(neutral: neutral,
+ low: low,
+ mid: blend(from: low, to: high, t: 0.5),
+ high: high,
+ intensity: intensity,
+ isDarkMode: isDarkMode)
+ default:
+ guard let stops = presetStops[scheme] else {
+ return color(for: .default, intensity: intensity, isDarkMode: isDarkMode)
+ }
+ let colors = isDarkMode ? stops.dark : stops.light
+ return threeStopColor(neutral: neutral,
+ low: colors[0],
+ mid: colors[1],
+ high: colors[2],
+ intensity: intensity,
+ isDarkMode: isDarkMode)
+ }
+ }
+
+ /// 设置页下拉项里的色条缩略图;在绘制时按当前 appearance 取色,深浅色切换后自动正确。
+ static func thumbnailImage(for scheme: HeatmapColorScheme, size: NSSize = NSSize(width: 36, height: 10)) -> NSImage {
+ let image = NSImage(size: size, flipped: false) { rect in
+ let isDarkMode = NSAppearance.currentDrawing().bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ let steps = 6
+ var colors: [NSColor] = []
+ var locations: [CGFloat] = []
+ for index in 0...steps {
+ let t = CGFloat(index) / CGFloat(steps)
+ colors.append(HeatmapPalette.color(for: scheme, intensity: max(0.05, t), isDarkMode: isDarkMode))
+ locations.append(t)
+ }
+ let path = NSBezierPath(roundedRect: rect, xRadius: 2.5, yRadius: 2.5)
+ locations.withUnsafeBufferPointer { buffer in
+ NSGradient(colors: colors, atLocations: buffer.baseAddress, colorSpace: .deviceRGB)?.draw(in: path, angle: 0)
+ }
+ return true
+ }
+ return image
+ }
+
+ static func blend(from: NSColor, to: NSColor, t: CGFloat) -> NSColor {
let progress = max(0, min(1, t))
let fromRGB = from.usingColorSpace(.deviceRGB) ?? from
let toRGB = to.usingColorSpace(.deviceRGB) ?? to
@@ -1332,4 +1502,108 @@ private final class KeyboardHeatmapView: NSView {
return NSColor(calibratedRed: red, green: green, blue: blue, alpha: alpha)
}
+
+ static func color(fromHex hex: String) -> NSColor? {
+ var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if value.hasPrefix("#") {
+ value.removeFirst()
+ }
+ guard value.count == 6, let rgbValue = UInt32(value, radix: 16) else { return nil }
+ return NSColor(srgbRed: CGFloat((rgbValue >> 16) & 0xFF) / 255.0,
+ green: CGFloat((rgbValue >> 8) & 0xFF) / 255.0,
+ blue: CGFloat(rgbValue & 0xFF) / 255.0,
+ alpha: 1)
+ }
+
+ static func hexString(from color: NSColor) -> String {
+ let srgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) ?? NSColor.black
+ let red = Int((max(0, min(1, srgb.redComponent)) * 255).rounded())
+ let green = Int((max(0, min(1, srgb.greenComponent)) * 255).rounded())
+ let blue = Int((max(0, min(1, srgb.blueComponent)) * 255).rounded())
+ return String(format: "#%02X%02X%02X", red, green, blue)
+ }
+
+ private static func storedColor(forKey key: String, fallbackHex: String) -> NSColor {
+ if let stored = UserDefaults.standard.string(forKey: key), let parsed = HeatmapPalette.color(fromHex: stored) {
+ return parsed
+ }
+ return HeatmapPalette.color(fromHex: fallbackHex) ?? .black
+ }
+
+ private static func threeStopColor(neutral: NSColor,
+ low lowStop: NSColor,
+ mid: NSColor,
+ high: NSColor,
+ intensity: CGFloat,
+ isDarkMode: Bool) -> NSColor {
+ // 低频键与底色混合,避免只按过一两次的键过于抢眼
+ let low = blend(from: neutral, to: lowStop, t: isDarkMode ? 0.45 : 0.40)
+ return intensity < 0.5
+ ? blend(from: low, to: mid, t: intensity / 0.5)
+ : blend(from: mid, to: high, t: (intensity - 0.5) / 0.5)
+ }
+
+ private static func rgb(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> NSColor {
+ NSColor(srgbRed: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: 1)
+ }
+}
+
+/// 设置页的色阶预览条:第一格为无按键底色,其余从低到高热度。
+final class HeatmapPalettePreviewView: NSView {
+ private let cellCount = 8
+ 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 var intrinsicContentSize: NSSize {
+ NSSize(width: NSView.noIntrinsicMetric, height: 18)
+ }
+
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ needsDisplay = true
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect)
+
+ let isDarkMode = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ let gap: CGFloat = 4
+ let cellWidth = (bounds.width - gap * CGFloat(cellCount - 1)) / CGFloat(cellCount)
+ guard cellWidth > 0 else { return }
+
+ let borderColor = isDarkMode ? NSColor.white.withAlphaComponent(0.14) : NSColor.black.withAlphaComponent(0.09)
+ for index in 0.. NSStackView {
+ heatmapColorSchemePopUp = NSPopUpButton()
+ heatmapColorSchemePopUp.controlSize = .small
+ heatmapColorSchemePopUp.font = NSFont.systemFont(ofSize: 12)
+ for scheme in HeatmapColorScheme.allCases {
+ heatmapColorSchemePopUp.addItem(withTitle: scheme.localizedTitle)
+ heatmapColorSchemePopUp.lastItem?.representedObject = scheme.rawValue
+ heatmapColorSchemePopUp.lastItem?.image = HeatmapPalette.thumbnailImage(for: scheme)
+ }
+ heatmapColorSchemePopUp.target = self
+ heatmapColorSchemePopUp.action = #selector(heatmapColorSchemeChanged)
+
+ let schemeRow = makeLabeledRow(title: NSLocalizedString("settings.heatmapColorScheme", comment: ""),
+ control: heatmapColorSchemePopUp)
+
+ heatmapCustomLowColorWell = makeHeatmapColorWell()
+ heatmapCustomHighColorWell = makeHeatmapColorWell()
+ let customColorsStack = makeOptionSeparatedStack([
+ makeLabeledRow(title: NSLocalizedString("settings.heatmapCustomLowColor", comment: ""),
+ control: heatmapCustomLowColorWell),
+ makeLabeledRow(title: NSLocalizedString("settings.heatmapCustomHighColor", comment: ""),
+ control: heatmapCustomHighColorWell)
+ ], spacing: 6)
+ heatmapCustomColorsContainer = makeVerticalStack(spacing: 6)
+ addArrangedSubviewFillingWidth(makeOptionDividerContainer(), to: heatmapCustomColorsContainer)
+ addArrangedSubviewFillingWidth(makeIndentedContainer(customColorsStack, indent: 22), to: heatmapCustomColorsContainer)
+
+ let lessLabel = NSTextField(labelWithString: NSLocalizedString("settings.heatmapPreview.less", comment: ""))
+ let moreLabel = NSTextField(labelWithString: NSLocalizedString("settings.heatmapPreview.more", comment: ""))
+ for label in [lessLabel, moreLabel] {
+ label.font = NSFont.systemFont(ofSize: 11)
+ label.textColor = .secondaryLabelColor
+ label.setContentHuggingPriority(.required, for: .horizontal)
+ }
+ heatmapPalettePreviewView = HeatmapPalettePreviewView(frame: .zero)
+ heatmapPalettePreviewView.translatesAutoresizingMaskIntoConstraints = false
+ heatmapPalettePreviewView.heightAnchor.constraint(equalToConstant: 18).isActive = true
+ heatmapPalettePreviewView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ let previewRow = NSStackView(views: [lessLabel, heatmapPalettePreviewView, moreLabel])
+ previewRow.orientation = .horizontal
+ previewRow.alignment = .centerY
+ previewRow.spacing = 8
+
+ let stack = makeVerticalStack(spacing: 8)
+ addArrangedSubviewFillingWidth(schemeRow, to: stack)
+ addArrangedSubviewFillingWidth(heatmapCustomColorsContainer, to: stack)
+ addArrangedSubviewFillingWidth(makeOptionDividerContainer(), to: stack)
+ addArrangedSubviewFillingWidth(previewRow, to: stack)
+ return stack
+ }
+
+ private func makeHeatmapColorWell() -> NSColorWell {
+ let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 44, height: 22))
+ colorWell.target = self
+ colorWell.action = #selector(heatmapCustomColorChanged(_:))
+ colorWell.translatesAutoresizingMaskIntoConstraints = false
+ colorWell.widthAnchor.constraint(equalToConstant: 44).isActive = true
+ colorWell.heightAnchor.constraint(equalToConstant: 22).isActive = true
+ return colorWell
+ }
+
private func makeSection(titleKey: String, content: NSView) -> NSView {
let titleLabel = makeSectionTitleLabel(NSLocalizedString(titleKey, comment: ""))
@@ -719,9 +788,20 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate {
showThresholdsButton.state = notificationsEnabled ? .on : .off
notificationThresholdContainer.isHidden = !notificationsEnabled
updateThresholdUI()
+ updateHeatmapColorSchemeState()
updateSyncState()
}
+ private func updateHeatmapColorSchemeState() {
+ let scheme = HeatmapPalette.scheme
+ if let item = heatmapColorSchemePopUp.itemArray.first(where: { ($0.representedObject as? String) == scheme.rawValue }) {
+ heatmapColorSchemePopUp.select(item)
+ }
+ heatmapCustomLowColorWell.color = HeatmapPalette.customLowColor
+ heatmapCustomHighColorWell.color = HeatmapPalette.customHighColor
+ heatmapCustomColorsContainer.isHidden = scheme != .custom
+ }
+
private func updateSyncState() {
let coordinator = SyncCoordinator.shared
let state = coordinator.state
@@ -1082,6 +1162,31 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate {
StatsManager.shared.menuBarUpdateHandler?()
}
+ @objc private func heatmapColorSchemeChanged() {
+ guard let rawValue = heatmapColorSchemePopUp.selectedItem?.representedObject as? String,
+ let scheme = HeatmapColorScheme(rawValue: rawValue),
+ scheme != HeatmapPalette.scheme else { return }
+ HeatmapPalette.scheme = scheme
+ heatmapCustomColorsContainer.isHidden = scheme != .custom
+ if scheme != .custom {
+ heatmapCustomLowColorWell.deactivate()
+ heatmapCustomHighColorWell.deactivate()
+ }
+ AppDelegate.trackClick("setting_heatmap_color_scheme", properties: ["scheme": rawValue])
+ }
+
+ @objc private func heatmapCustomColorChanged(_ sender: NSColorWell) {
+ if sender === heatmapCustomLowColorWell {
+ HeatmapPalette.customLowColor = sender.color
+ } else {
+ HeatmapPalette.customHighColor = sender.color
+ }
+ // 自定义项的色条缩略图需按新颜色重建
+ if let item = heatmapColorSchemePopUp.itemArray.first(where: { ($0.representedObject as? String) == HeatmapColorScheme.custom.rawValue }) {
+ item.image = HeatmapPalette.thumbnailImage(for: .custom)
+ }
+ }
+
@objc private func toggleLaunchAtLogin() {
let shouldEnable = launchAtLoginButton.state == .on
do {
diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings
index 729676fa..0a86356a 100644
--- a/KeyStats/en.lproj/Localizable.strings
+++ b/KeyStats/en.lproj/Localizable.strings
@@ -381,3 +381,19 @@
"hourly.summary.metric" = "Synced total: %ld · This Mac: %ld · Busiest hour: %@";
"hourly.detail.metric" = "%@ · Synced total: %@ · This Mac: %@";
"hourly.import.differentTimeZones" = "These hourly records use different time zones and cannot be merged accurately. Use Replace to restore the backup instead.";
+
+/* Heatmap color scheme */
+"settings.section.heatmap" = "Heatmap Colors";
+"settings.heatmapColorScheme" = "Key Colors";
+"settings.heatmapColorScheme.default" = "Default (Blue)";
+"settings.heatmapColorScheme.warm" = "Warm (Orange-Red)";
+"settings.heatmapColorScheme.spectrum" = "Spectrum (Blue-Yellow-Red)";
+"settings.heatmapColorScheme.green" = "Green";
+"settings.heatmapColorScheme.teal" = "Teal";
+"settings.heatmapColorScheme.purple" = "Purple";
+"settings.heatmapColorScheme.rose" = "Rose";
+"settings.heatmapColorScheme.custom" = "Custom";
+"settings.heatmapCustomLowColor" = "Start Color";
+"settings.heatmapCustomHighColor" = "Peak Color";
+"settings.heatmapPreview.less" = "Less";
+"settings.heatmapPreview.more" = "More";
diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings
index 66eb40a7..36a43dc9 100644
--- a/KeyStats/zh-Hans.lproj/Localizable.strings
+++ b/KeyStats/zh-Hans.lproj/Localizable.strings
@@ -381,3 +381,19 @@
"hourly.summary.metric" = "总数:%ld · 本设备:%ld · 最活跃时段:%@";
"hourly.detail.metric" = "%@ · 总数:%@ · 本设备:%@";
"hourly.import.differentTimeZones" = "小时记录的时区不同,无法准确合并。请使用覆盖导入恢复备份。";
+
+/* Heatmap color scheme */
+"settings.section.heatmap" = "热力图配色";
+"settings.heatmapColorScheme" = "键盘热力图的按键颜色";
+"settings.heatmapColorScheme.default" = "默认(蓝色)";
+"settings.heatmapColorScheme.warm" = "暖色(橙红)";
+"settings.heatmapColorScheme.spectrum" = "光谱(蓝黄红)";
+"settings.heatmapColorScheme.green" = "绿色";
+"settings.heatmapColorScheme.teal" = "青色";
+"settings.heatmapColorScheme.purple" = "紫色";
+"settings.heatmapColorScheme.rose" = "玫红";
+"settings.heatmapColorScheme.custom" = "自定义";
+"settings.heatmapCustomLowColor" = "起始色";
+"settings.heatmapCustomHighColor" = "最高色";
+"settings.heatmapPreview.less" = "少";
+"settings.heatmapPreview.more" = "多";
diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings
index 610d2d6a..116f79f4 100644
--- a/KeyStats/zh-Hant.lproj/Localizable.strings
+++ b/KeyStats/zh-Hant.lproj/Localizable.strings
@@ -381,3 +381,19 @@
"hourly.summary.metric" = "總數:%ld · 本裝置:%ld · 最活躍時段:%@";
"hourly.detail.metric" = "%@ · 總數:%@ · 本裝置:%@";
"hourly.import.differentTimeZones" = "小時記錄的時區不同,無法準確合併。請使用覆蓋匯入還原備份。";
+
+/* Heatmap color scheme */
+"settings.section.heatmap" = "熱力圖配色";
+"settings.heatmapColorScheme" = "鍵盤熱力圖的按鍵顏色";
+"settings.heatmapColorScheme.default" = "預設(藍色)";
+"settings.heatmapColorScheme.warm" = "暖色(橙紅)";
+"settings.heatmapColorScheme.spectrum" = "光譜(藍黃紅)";
+"settings.heatmapColorScheme.green" = "綠色";
+"settings.heatmapColorScheme.teal" = "青色";
+"settings.heatmapColorScheme.purple" = "紫色";
+"settings.heatmapColorScheme.rose" = "玫紅";
+"settings.heatmapColorScheme.custom" = "自訂";
+"settings.heatmapCustomLowColor" = "起始色";
+"settings.heatmapCustomHighColor" = "最高色";
+"settings.heatmapPreview.less" = "少";
+"settings.heatmapPreview.more" = "多";
From 03f5b91717bcb5ea32d588bcaabc3cdae322aa20 Mon Sep 17 00:00:00 2001
From: pipizhu
Date: Thu, 22 Oct 2026 17:32:58 +0800
Subject: [PATCH 3/9] fix(heatmap): keep key legends readable across color
schemes
---
.../HeatmapPaletteTests.cs | 61 +++++++++++++++++++
.../KeyStats/Helpers/HeatmapPalette.cs | 17 ++++++
.../Views/Controls/KeyboardHeatmapControl.cs | 6 +-
KeyStats/KeyboardHeatmapViewController.swift | 20 +++++-
4 files changed, 99 insertions(+), 5 deletions(-)
create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs
new file mode 100644
index 00000000..892b7dfd
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Windows.Media;
+using KeyStats.Helpers;
+using KeyStats.Models;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace KeyStats.Sync.Tests;
+
+[TestClass]
+public sealed class HeatmapPaletteTests
+{
+ [TestMethod]
+ public void CustomPeakColorsRemainReadableInBothThemes()
+ {
+ foreach (var dark in new[] { false, true })
+ {
+ foreach (var peak in new[] { Colors.White, Colors.Black })
+ {
+ var fill = HeatmapPalette.ColorFor(AppSettings.HeatmapColorSchemeCustom,
+ Colors.Gray, peak, 1, dark);
+ Assert.AreEqual(peak, fill);
+ Assert.AreEqual(peak == Colors.White ? Colors.Black : Colors.White,
+ HeatmapPalette.LegendColor(fill));
+ }
+ }
+ }
+
+ [TestMethod]
+ public void PresetAndCustomGradientsMaintainLegibleContrast()
+ {
+ var schemes = new[]
+ {
+ AppSettings.HeatmapColorSchemeDefault, AppSettings.HeatmapColorSchemeWarm,
+ AppSettings.HeatmapColorSchemeSpectrum, AppSettings.HeatmapColorSchemeGreen,
+ AppSettings.HeatmapColorSchemeTeal, AppSettings.HeatmapColorSchemePurple,
+ AppSettings.HeatmapColorSchemeRose, AppSettings.HeatmapColorSchemeCustom
+ };
+ foreach (var dark in new[] { false, true })
+ foreach (var scheme in schemes)
+ for (var step = 0; step <= 10; step++)
+ {
+ var fill = HeatmapPalette.ColorFor(scheme, Colors.White, Colors.Black, step / 10.0, dark);
+ var foreground = HeatmapPalette.LegendColor(fill);
+ var backgroundLuminance = Luminance(fill);
+ var foregroundLuminance = Luminance(foreground);
+ var contrast = (Math.Max(backgroundLuminance, foregroundLuminance) + 0.05)
+ / (Math.Min(backgroundLuminance, foregroundLuminance) + 0.05);
+ Assert.IsTrue(contrast >= 4.5, $"{scheme}, dark={dark}, step={step}: {contrast}");
+ }
+ }
+
+ private static double Luminance(Color color)
+ {
+ double Linear(byte component)
+ {
+ var value = component / 255.0;
+ return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B);
+ }
+}
diff --git a/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
index 42a090e4..d6626648 100644
--- a/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
+++ b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
@@ -135,6 +135,23 @@ public static Color ParseColor(string? hex, Color fallback)
}
}
+ /// 为实际键帽底色选择对比度更高的黑色或白色文字。
+ public static Color LegendColor(Color background)
+ {
+ double Linear(byte component)
+ {
+ var value = component / 255.0;
+ return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4);
+ }
+
+ var luminance = 0.2126 * Linear(background.R)
+ + 0.7152 * Linear(background.G)
+ + 0.0722 * Linear(background.B);
+ var blackContrast = (luminance + 0.05) / 0.05;
+ var whiteContrast = 1.05 / (luminance + 0.05);
+ return blackContrast >= whiteContrast ? Colors.Black : Colors.White;
+ }
+
public static string ToHex(Color color)
{
return string.Format(CultureInfo.InvariantCulture, "#{0:X2}{1:X2}{2:X2}", color.R, color.G, color.B);
diff --git a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
index 91079cc6..4d5c54f2 100644
--- a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
+++ b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
@@ -75,8 +75,6 @@ protected override void OnRender(DrawingContext drawingContext)
}
var isDarkMode = ThemeManager.Instance.IsDarkTheme;
- var defaultTextColor = isDarkMode ? Colors.White : ResolveColor("TextPrimaryBrush", Colors.Black);
- var textBrush = CreateBrush(defaultTextColor);
var borderColor = isDarkMode
? Color.FromArgb(36, 255, 255, 255)
: Color.FromArgb(23, 0, 0, 0);
@@ -88,10 +86,12 @@ protected override void OnRender(DrawingContext drawingContext)
var frame = KeyFrame(key, metrics);
var count = _keyCounts.TryGetValue(key.Id, out var value) ? value : 0;
var fillColor = ColorForCount(count, isDarkMode);
+ var textColor = HeatmapPalette.LegendColor(fillColor);
+ var textBrush = CreateBrush(textColor);
var radius = Math.Max(4, metrics.Scale * 0.14);
drawingContext.DrawRoundedRectangle(CreateBrush(fillColor), borderPen, frame, radius, radius);
- DrawKeyLegend(drawingContext, key, frame, metrics.Scale, textBrush, defaultTextColor);
+ DrawKeyLegend(drawingContext, key, frame, metrics.Scale, textBrush, textColor);
DrawCountBadge(drawingContext, count, frame, metrics.Scale, isDarkMode);
}
}
diff --git a/KeyStats/KeyboardHeatmapViewController.swift b/KeyStats/KeyboardHeatmapViewController.swift
index 1a7ad336..584a3599 100644
--- a/KeyStats/KeyboardHeatmapViewController.swift
+++ b/KeyStats/KeyboardHeatmapViewController.swift
@@ -1030,12 +1030,12 @@ private final class KeyboardHeatmapView: NSView {
guard let metrics = renderMetrics() else { return }
let isDarkMode = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- let labelColor: NSColor = isDarkMode ? .white : .labelColor
for key in Self.layout {
let frame = keyFrame(for: key, metrics: metrics)
let count = count(for: key)
let fillColor = color(for: count, isDarkMode: isDarkMode)
+ let labelColor = HeatmapPalette.legendColor(on: fillColor)
let keyPath = NSBezierPath(roundedRect: frame, xRadius: max(4, metrics.scale * 0.14), yRadius: max(4, metrics.scale * 0.14))
fillColor.setFill()
@@ -1132,7 +1132,8 @@ private final class KeyboardHeatmapView: NSView {
)
if let symbolImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
- .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: symbolWeight)) {
+ .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: symbolWeight)
+ .applying(NSImage.SymbolConfiguration(paletteColors: [textColor]))) {
symbolImage.draw(in: symbolRect)
}
@@ -1503,6 +1504,21 @@ enum HeatmapPalette {
return NSColor(calibratedRed: red, green: green, blue: blue, alpha: alpha)
}
+ /// Selects the higher-contrast foreground for the actual keycap color.
+ static func legendColor(on background: NSColor) -> NSColor {
+ let rgb = background.usingColorSpace(.sRGB) ?? .black
+ func linear(_ component: CGFloat) -> CGFloat {
+ let value = max(0, min(1, component))
+ return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
+ }
+ let luminance = 0.2126 * linear(rgb.redComponent)
+ + 0.7152 * linear(rgb.greenComponent)
+ + 0.0722 * linear(rgb.blueComponent)
+ let blackContrast = (luminance + 0.05) / 0.05
+ let whiteContrast = 1.05 / (luminance + 0.05)
+ return blackContrast >= whiteContrast ? .black : .white
+ }
+
static func color(fromHex hex: String) -> NSColor? {
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("#") {
From 092177359fcf91bc74fc5bb2557c4b2258985c1f Mon Sep 17 00:00:00 2001
From: pipizhu
Date: Thu, 22 Oct 2026 17:36:00 +0800
Subject: [PATCH 4/9] test(heatmap): enable WPF references for palette
regression tests
---
KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj | 1 +
1 file changed, 1 insertion(+)
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
index 99fc3e67..fd0320b6 100644
--- a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
@@ -2,6 +2,7 @@
net48
+ true
10.0
enable
disable
From 65070aa55587a997bc3ba80b7dc69b8b434e0efd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Fri, 23 Oct 2026 10:09:22 +0800
Subject: [PATCH 5/9] feat(year-review): add year in review window on Windows
Add a Year in Review window with an activity wall (GitHub-style daily grid,
past 12 months or calendar year, keys or clicks, quartile color levels that
follow the heatmap palette, streak and best-day summary) and a keyboard
personality tab (shortcut ratio, regret index, copy/paste, undo, arrow keys,
click ratio, top keys). Entry points: stats popup header and tray menu.
Persona rules live in KeyboardPersonaAnalyzer with unit tests.
---
.../KeyboardPersonaAnalyzerTests.cs | 129 ++++++
KeyStats.Windows/KeyStats/App.xaml.cs | 23 +
.../KeyStats/Properties/Strings.cs | 51 +++
.../KeyStats/Properties/Strings.resx | 51 +++
.../KeyStats/Properties/Strings.zh-Hans.resx | 51 +++
.../KeyStats/Properties/Strings.zh-Hant.resx | 51 +++
.../Services/KeyboardPersonaAnalyzer.cs | 160 +++++++
.../Views/Controls/ContributionWallControl.cs | 285 ++++++++++++
.../KeyStats/Views/StatsPopupWindow.xaml | 10 +
.../KeyStats/Views/StatsPopupWindow.xaml.cs | 6 +
.../KeyStats/Views/YearReviewWindow.xaml | 257 +++++++++++
.../KeyStats/Views/YearReviewWindow.xaml.cs | 427 ++++++++++++++++++
12 files changed, 1501 insertions(+)
create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs
create mode 100644 KeyStats.Windows/KeyStats/Services/KeyboardPersonaAnalyzer.cs
create mode 100644 KeyStats.Windows/KeyStats/Views/Controls/ContributionWallControl.cs
create mode 100644 KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml
create mode 100644 KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs
new file mode 100644
index 00000000..dc7feacf
--- /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 d70f2371..4c484897 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;
@@ -227,6 +228,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 +484,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 线程上执行
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs
index b359298b..765b862c 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.cs
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs
@@ -66,6 +66,57 @@ 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 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 8a008a54..98cff26d 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx
@@ -123,6 +123,57 @@
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
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 42b4f4e3..304a2cca 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
@@ -123,6 +123,57 @@
最高色
少
多
+ 年度回顾 - 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} 次点击
需要重启
需要重启 KeyStats 才能切换语言。是否立即重启?
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
index 655f800d..e0f6912f 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
@@ -123,6 +123,57 @@
最高色
少
多
+ 年度回顧 - 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} 次點擊
需要重新啟動
KeyStats 需要重新啟動才能套用新的語言。要立即重新啟動嗎?
diff --git a/KeyStats.Windows/KeyStats/Services/KeyboardPersonaAnalyzer.cs b/KeyStats.Windows/KeyStats/Services/KeyboardPersonaAnalyzer.cs
new file mode 100644
index 00000000..b7d79e4c
--- /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/Views/Controls/ContributionWallControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/ContributionWallControl.cs
new file mode 100644
index 00000000..950623f9
--- /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/StatsPopupWindow.xaml b/KeyStats.Windows/KeyStats/Views/StatsPopupWindow.xaml
index aefbbe7c..10f2c1ec 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 00000000..66ad812b
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs
@@ -0,0 +1,427 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+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 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;
+ _isInitialized = true;
+ RefreshWall();
+ RefreshPersona();
+
+ 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;
+ _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();
+ }
+
+ 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 isWall = WallTabButton.IsChecked == true;
+ WallPanel.Visibility = isWall ? Visibility.Visible : Visibility.Collapsed;
+ PersonaPanel.Visibility = isWall ? Visibility.Collapsed : Visibility.Visible;
+ App.CurrentApp?.TrackClick("year_review_tab", new Dictionary
+ {
+ ["tab"] = isWall ? "wall" : "persona"
+ });
+ }
+
+ 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 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; }
+ }
+}
From fc6df0b5c6fa7c0c2a3cbb171e5706faea1eb689 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Fri, 23 Oct 2026 10:09:23 +0800
Subject: [PATCH 6/9] feat(year-review): add year in review window on macOS
Mirror the Windows Year in Review window: activity wall and keyboard
personality tabs with the same rules, opened from the stats popover.
Pure logic lives in YearReviewModels.swift (added to KeyStatsCore) with
unit tests.
---
KeyStats.xcodeproj/project.pbxproj | 8 +
KeyStats/StatsManager.swift | 5 +
KeyStats/StatsPopoverViewController.swift | 28 +
KeyStats/YearReviewModels.swift | 392 +++++++++
KeyStats/YearReviewWindowController.swift | 973 +++++++++++++++++++++
KeyStats/en.lproj/Localizable.strings | 52 ++
KeyStats/zh-Hans.lproj/Localizable.strings | 52 ++
KeyStats/zh-Hant.lproj/Localizable.strings | 52 ++
KeyStatsTests/YearReviewModelsTests.swift | 180 ++++
Package.swift | 8 +-
10 files changed, 1747 insertions(+), 3 deletions(-)
create mode 100644 KeyStats/YearReviewModels.swift
create mode 100644 KeyStats/YearReviewWindowController.swift
create mode 100644 KeyStatsTests/YearReviewModelsTests.swift
diff --git a/KeyStats.xcodeproj/project.pbxproj b/KeyStats.xcodeproj/project.pbxproj
index 520dc5a7..b373deb5 100644
--- a/KeyStats.xcodeproj/project.pbxproj
+++ b/KeyStats.xcodeproj/project.pbxproj
@@ -43,6 +43,8 @@
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 */; };
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 +114,8 @@
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 = ""; };
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 +206,8 @@
A2000025 /* AppStatsViewController.swift */,
A2000030 /* KeyboardHeatmapWindowController.swift */,
A2000031 /* KeyboardHeatmapViewController.swift */,
+ D2000001 /* YearReviewModels.swift */,
+ D2000002 /* YearReviewWindowController.swift */,
A2000008 /* MainWindowController.swift */,
A2000009 /* MainWindowViewController.swift */,
A2000016 /* HoverIconButton.swift */,
@@ -413,6 +419,8 @@
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift
index 48b2bc79..45c6a38a 100644
--- a/KeyStats/StatsManager.swift
+++ b/KeyStats/StatsManager.swift
@@ -1690,6 +1690,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 6a4a042a..4ffd3d8b 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 00000000..cf659431
--- /dev/null
+++ b/KeyStats/YearReviewModels.swift
@@ -0,0 +1,392 @@
+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
+ }
+}
diff --git a/KeyStats/YearReviewWindowController.swift b/KeyStats/YearReviewWindowController.swift
new file mode 100644
index 00000000..a26f3564
--- /dev/null
+++ b/KeyStats/YearReviewWindowController.swift
@@ -0,0 +1,973 @@
+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!
+
+ 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)
+ }
+
+ 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")],
+ 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
+
+ view.addSubview(titleLabel)
+ view.addSubview(tabControl)
+ view.addSubview(wallTabView)
+ view.addSubview(personaTabView)
+
+ 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 [wallTabView as NSView, personaTabView as NSView] {
+ 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),
+ ])
+ }
+ }
+
+ @objc private func tabChanged() {
+ let showsWall = tabControl.selectedSegment == 0
+ wallTabView.isHidden = !showsWall
+ personaTabView.isHidden = showsWall
+ AppDelegate.trackClick("year_review_tab", properties: ["tab": showsWall ? "wall" : "persona"])
+ }
+}
+
+// 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
+ row.heightAnchor.constraint(equalToConstant: 76).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 }
+}
diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings
index 0a86356a..9a0d4456 100644
--- a/KeyStats/en.lproj/Localizable.strings
+++ b/KeyStats/en.lproj/Localizable.strings
@@ -397,3 +397,55 @@
"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.";
diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings
index 36a43dc9..d0983439 100644
--- a/KeyStats/zh-Hans.lproj/Localizable.strings
+++ b/KeyStats/zh-Hans.lproj/Localizable.strings
@@ -397,3 +397,55 @@
"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" = "键鼠双修,张弛有度";
diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings
index 116f79f4..8dfd4e3b 100644
--- a/KeyStats/zh-Hant.lproj/Localizable.strings
+++ b/KeyStats/zh-Hant.lproj/Localizable.strings
@@ -397,3 +397,55 @@
"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" = "鍵鼠雙修,張弛有度";
diff --git a/KeyStatsTests/YearReviewModelsTests.swift b/KeyStatsTests/YearReviewModelsTests.swift
new file mode 100644
index 00000000..46fe239c
--- /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 6bc55cdc..37e91dc2 100644
--- a/Package.swift
+++ b/Package.swift
@@ -50,7 +50,8 @@ let package = Package(
"KeyboardHeatmapWindowController.swift",
"StatsPopoverViewController.swift",
"SettingsWindowController.swift",
- "MainWindowViewController.swift"
+ "MainWindowViewController.swift",
+ "YearReviewWindowController.swift"
],
sources: [
"AppStats.swift",
@@ -61,14 +62,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", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift"]
+ sources: ["HourlyStatsTests.swift", "AppStatsTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift", "YearReviewModelsTests.swift"]
)
]
)
From 773121f12274fa49c08a0fba21acf96ff5b58b6f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Fri, 23 Oct 2026 10:18:44 +0800
Subject: [PATCH 7/9] feat(year-review): add active hours and achievements on
Windows
Record key presses and clicks per local hour in DailyStats (new optional
hourlyKeyPresses/hourlyClicks fields; legacy data reads as zeros, merge
sums per hour, not part of the sync protocol). Year in Review gains an
Active Hours tab (daily-average bar chart, peak hour, chronotype, night
share) and an Achievements tab (16 milestones with progress and unlock
dates). AchievementService evaluates every 60s, backfills silently on
first run and toasts new unlocks; the toast can be turned off in
notification settings.
---
.../ActivityInsightsTests.cs | 159 +++++++++++++
KeyStats.Windows/KeyStats/App.xaml.cs | 2 +
.../KeyStats/Models/AppSettings.cs | 12 +
.../KeyStats/Models/DailyStats.cs | 58 +++++
.../KeyStats/Properties/Strings.cs | 51 ++++
.../KeyStats/Properties/Strings.resx | 51 ++++
.../KeyStats/Properties/Strings.zh-Hans.resx | 51 ++++
.../KeyStats/Properties/Strings.zh-Hant.resx | 51 ++++
.../KeyStats/Services/AchievementEvaluator.cs | 167 ++++++++++++++
.../KeyStats/Services/AchievementService.cs | 160 +++++++++++++
.../Services/DisplayStatsAggregator.cs | 4 +-
.../Services/HourlyActivityAnalyzer.cs | 115 ++++++++++
.../KeyStats/Services/NotificationService.cs | 15 ++
.../KeyStats/Services/StatsManager.cs | 24 +-
.../Controls/HourlyActivityChartControl.cs | 164 +++++++++++++
.../Views/NotificationSettingsWindow.xaml | 12 +
.../Views/NotificationSettingsWindow.xaml.cs | 13 ++
.../KeyStats/Views/YearReviewWindow.xaml | 133 +++++++++++
.../KeyStats/Views/YearReviewWindow.xaml.cs | 217 +++++++++++++++++-
19 files changed, 1449 insertions(+), 10 deletions(-)
create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs
create mode 100644 KeyStats.Windows/KeyStats/Services/AchievementEvaluator.cs
create mode 100644 KeyStats.Windows/KeyStats/Services/AchievementService.cs
create mode 100644 KeyStats.Windows/KeyStats/Services/HourlyActivityAnalyzer.cs
create mode 100644 KeyStats.Windows/KeyStats/Views/Controls/HourlyActivityChartControl.cs
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs
new file mode 100644
index 00000000..5e97add1
--- /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/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs
index 4c484897..ec6936e5 100644
--- a/KeyStats.Windows/KeyStats/App.xaml.cs
+++ b/KeyStats.Windows/KeyStats/App.xaml.cs
@@ -127,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();
@@ -672,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 4319165e..ddfda7a1 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 bc74e7d0..7f4567c5 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 765b862c..33074678 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.cs
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs
@@ -117,6 +117,57 @@ public static class Strings
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 98cff26d..8546ba60 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx
@@ -174,6 +174,57 @@
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 304a2cca..bc63b5eb 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
@@ -174,6 +174,57 @@
年度回顾
年度回顾
基于 {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 e0f6912f..ac954fff 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
@@ -174,6 +174,57 @@
年度回顧
年度回顧
基於 {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 00000000..afec383a
--- /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 00000000..706dcf59
--- /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 e62bdca4..e295a7cf 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 00000000..42f70576
--- /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/NotificationService.cs b/KeyStats.Windows/KeyStats/Services/NotificationService.cs
index e3625f86..1e8fdc65 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 13446dd9..12b8bfce 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/HourlyActivityChartControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/HourlyActivityChartControl.cs
new file mode 100644
index 00000000..eeb66628
--- /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 e1a2ee69..2cd6103c 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/YearReviewWindow.xaml b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml
index 9209d0e5..3061e742 100644
--- a/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml
+++ b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml
@@ -117,6 +117,12 @@
+
+
@@ -252,6 +258,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs
index 66ad812b..afd7c9a8 100644
--- a/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs
+++ b/KeyStats.Windows/KeyStats/Views/YearReviewWindow.xaml.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Media;
using System.Windows.Threading;
using KeyStats.Helpers;
using KeyStats.Models;
@@ -20,6 +21,7 @@ public partial class YearReviewWindow : Window
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) };
@@ -34,9 +36,14 @@ public YearReviewWindow()
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;
@@ -56,6 +63,7 @@ private void OnClosed(object? sender, EventArgs e)
{
ThemeManager.Instance.ThemeChanged -= OnThemeChanged;
StatsManager.Instance.StatsUpdateRequested -= OnStatsUpdateRequested;
+ AchievementService.Instance.AchievementsChanged -= OnAchievementsChanged;
_refreshTimer.Stop();
_refreshTimer.Tick -= OnRefreshTimerTick;
}
@@ -84,6 +92,8 @@ private void OnRefreshTimerTick(object? sender, EventArgs e)
LoadHistory();
RefreshWall();
RefreshPersona();
+ RefreshHourly();
+ RefreshAchievements();
}
private void ApplyWindowBackdrop()
@@ -333,12 +343,17 @@ private void Tab_Checked(object sender, RoutedEventArgs e)
return;
}
- var isWall = WallTabButton.IsChecked == true;
- WallPanel.Visibility = isWall ? Visibility.Visible : Visibility.Collapsed;
- PersonaPanel.Visibility = isWall ? Visibility.Collapsed : Visibility.Visible;
+ 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"] = isWall ? "wall" : "persona"
+ ["tab"] = tab
});
}
@@ -396,6 +411,200 @@ private void PersonaRange_Checked(object sender, RoutedEventArgs e)
});
}
+ 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)
From 155dd22f6e01586dd4adf8427d9beebe0b66e451 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Fri, 23 Oct 2026 10:18:45 +0800
Subject: [PATCH 8/9] feat(year-review): add active hours and achievements on
macOS
Mirror the Windows tabs on macOS. Active Hours reuses the existing
HourlyStats data instead of adding new DailyStats fields. AchievementManager
evaluates every 60s, backfills silently on first run and sends unlock
notifications, with a toggle in notification settings.
---
KeyStats.xcodeproj/project.pbxproj | 4 +
KeyStats/AchievementManager.swift | 133 +++++
KeyStats/AppDelegate.swift | 2 +
KeyStats/NotificationManager.swift | 25 +
KeyStats/SettingsViewController.swift | 14 +
KeyStats/YearReviewModels.swift | 302 ++++++++++
KeyStats/YearReviewWindowController.swift | 608 ++++++++++++++++++++-
KeyStats/en.lproj/Localizable.strings | 51 ++
KeyStats/zh-Hans.lproj/Localizable.strings | 51 ++
KeyStats/zh-Hant.lproj/Localizable.strings | 51 ++
KeyStatsTests/ActivityInsightsTests.swift | 191 +++++++
Package.swift | 5 +-
12 files changed, 1429 insertions(+), 8 deletions(-)
create mode 100644 KeyStats/AchievementManager.swift
create mode 100644 KeyStatsTests/ActivityInsightsTests.swift
diff --git a/KeyStats.xcodeproj/project.pbxproj b/KeyStats.xcodeproj/project.pbxproj
index b373deb5..1dd6ec59 100644
--- a/KeyStats.xcodeproj/project.pbxproj
+++ b/KeyStats.xcodeproj/project.pbxproj
@@ -45,6 +45,7 @@
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 */; };
@@ -116,6 +117,7 @@
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 = ""; };
@@ -208,6 +210,7 @@
A2000031 /* KeyboardHeatmapViewController.swift */,
D2000001 /* YearReviewModels.swift */,
D2000002 /* YearReviewWindowController.swift */,
+ D2000003 /* AchievementManager.swift */,
A2000008 /* MainWindowController.swift */,
A2000009 /* MainWindowViewController.swift */,
A2000016 /* HoverIconButton.swift */,
@@ -421,6 +424,7 @@
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 00000000..0314fb79
--- /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 ef776479..0743f43f 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 701c5b22..0348aa84 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 f919848f..08c6b7da 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/YearReviewModels.swift b/KeyStats/YearReviewModels.swift
index cf659431..13e9996b 100644
--- a/KeyStats/YearReviewModels.swift
+++ b/KeyStats/YearReviewModels.swift
@@ -390,3 +390,305 @@ enum ContributionWallBuilder {
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
index a26f3564..dc7e5999 100644
--- a/KeyStats/YearReviewWindowController.swift
+++ b/KeyStats/YearReviewWindowController.swift
@@ -75,6 +75,17 @@ final class YearReviewViewController: NSViewController {
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))
@@ -90,6 +101,8 @@ final class YearReviewViewController: NSViewController {
let history = StatsManager.shared.yearReviewHistorySnapshot()
wallTabView.apply(history: history)
personaTabView.apply(history: history)
+ hourlyTabView.apply(hourly: StatsManager.shared.hourlyStatsSnapshot())
+ achievementsTabView.reload()
}
private func setupUI() {
@@ -99,7 +112,9 @@ final class YearReviewViewController: NSViewController {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
tabControl = NSSegmentedControl(labels: [yearReviewLocalized("yearReview.tab.wall"),
- yearReviewLocalized("yearReview.tab.persona")],
+ yearReviewLocalized("yearReview.tab.persona"),
+ yearReviewLocalized("yearReview.tab.hourly"),
+ yearReviewLocalized("yearReview.tab.achievements")],
trackingMode: .selectOne,
target: self,
action: #selector(tabChanged))
@@ -111,11 +126,27 @@ final class YearReviewViewController: NSViewController {
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),
@@ -126,7 +157,7 @@ final class YearReviewViewController: NSViewController {
tabControl.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 16),
])
- for tabView in [wallTabView as NSView, personaTabView as NSView] {
+ for tabView in tabViews {
NSLayoutConstraint.activate([
tabView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
tabView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 28),
@@ -136,11 +167,16 @@ final class YearReviewViewController: NSViewController {
}
}
+ private var tabViews: [NSView] {
+ [wallTabView, personaTabView, hourlyTabView, achievementsTabView]
+ }
+
@objc private func tabChanged() {
- let showsWall = tabControl.selectedSegment == 0
- wallTabView.isHidden = !showsWall
- personaTabView.isHidden = showsWall
- AppDelegate.trackClick("year_review_tab", properties: ["tab": showsWall ? "wall" : "persona"])
+ 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]])
}
}
@@ -971,3 +1007,563 @@ final class KeyboardPersonaTabView: NSView {
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 9a0d4456..5966d6b8 100644
--- a/KeyStats/en.lproj/Localizable.strings
+++ b/KeyStats/en.lproj/Localizable.strings
@@ -449,3 +449,54 @@
"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 d0983439..61e3aec2 100644
--- a/KeyStats/zh-Hans.lproj/Localizable.strings
+++ b/KeyStats/zh-Hans.lproj/Localizable.strings
@@ -449,3 +449,54 @@
"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 8dfd4e3b..60e07037 100644
--- a/KeyStats/zh-Hant.lproj/Localizable.strings
+++ b/KeyStats/zh-Hant.lproj/Localizable.strings
@@ -449,3 +449,54 @@
"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 00000000..b238acfe
--- /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/Package.swift b/Package.swift
index 37e91dc2..adc08c40 100644
--- a/Package.swift
+++ b/Package.swift
@@ -51,7 +51,8 @@ let package = Package(
"StatsPopoverViewController.swift",
"SettingsWindowController.swift",
"MainWindowViewController.swift",
- "YearReviewWindowController.swift"
+ "YearReviewWindowController.swift",
+ "AchievementManager.swift"
],
sources: [
"AppStats.swift",
@@ -70,7 +71,7 @@ let package = Package(
name: "KeyStatsCoreTests",
dependencies: ["KeyStatsCore"],
path: "KeyStatsTests",
- sources: ["HourlyStatsTests.swift", "AppStatsTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift", "YearReviewModelsTests.swift"]
+ sources: ["HourlyStatsTests.swift", "AppStatsTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift", "YearReviewModelsTests.swift", "ActivityInsightsTests.swift"]
)
]
)
From 8e6a052bc8aa9bda504ccf737b9bd4b771f35937 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Fri, 23 Oct 2026 11:08:33 +0800
Subject: [PATCH 9/9] fix(year-review): stop clipping persona card detail lines
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
人格页卡片行高写死 76pt,而「标题 + 数值 + 说明」三行加上下 12pt
内边距实测需要 84pt,导致说明那行被卡片底边截掉一半(深浅色均复现)。
改为 84pt,与活跃时段 / 贡献墙页同款卡片的留白一致。
---
KeyStats/YearReviewWindowController.swift | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/KeyStats/YearReviewWindowController.swift b/KeyStats/YearReviewWindowController.swift
index dc7e5999..a9a9fc92 100644
--- a/KeyStats/YearReviewWindowController.swift
+++ b/KeyStats/YearReviewWindowController.swift
@@ -856,7 +856,8 @@ final class KeyboardPersonaTabView: NSView {
row.distribution = .fillEqually
row.spacing = 10
row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 76).isActive = true
+ // 卡片「标题 + 数值 + 说明」三行加上下内边距实测需要 84pt,低于此值说明行会被截断
+ row.heightAnchor.constraint(equalToConstant: 84).isActive = true
rows.append(row)
index += 2
}