From 3712b354ad098e1827c8939a18105e74acb2c04c Mon Sep 17 00:00:00 2001 From: pipizhu Date: Thu, 10 Sep 2026 18:06:12 +0800 Subject: [PATCH 1/8] feat(macos): add local hourly input trends --- KeyStats.xcodeproj/project.pbxproj | 16 ++ KeyStats/HourlyStats.swift | 67 ++++++++ KeyStats/HourlyStatsChartView.swift | 151 +++++++++++++++++ KeyStats/HourlyStatsViewController.swift | 178 +++++++++++++++++++++ KeyStats/HourlyStatsWindowController.swift | 26 +++ KeyStats/StatsManager.swift | 22 +++ KeyStats/StatsModels.swift | 2 +- KeyStats/StatsPopoverViewController.swift | 16 ++ KeyStats/en.lproj/Localizable.strings | 14 ++ KeyStats/zh-Hans.lproj/Localizable.strings | 14 ++ KeyStats/zh-Hant.lproj/Localizable.strings | 14 ++ KeyStatsTests/HourlyStatsTests.swift | 89 +++++++++++ Package.swift | 6 +- 13 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 KeyStats/HourlyStats.swift create mode 100644 KeyStats/HourlyStatsChartView.swift create mode 100644 KeyStats/HourlyStatsViewController.swift create mode 100644 KeyStats/HourlyStatsWindowController.swift create mode 100644 KeyStatsTests/HourlyStatsTests.swift diff --git a/KeyStats.xcodeproj/project.pbxproj b/KeyStats.xcodeproj/project.pbxproj index a5ebe6e..5e64243 100644 --- a/KeyStats.xcodeproj/project.pbxproj +++ b/KeyStats.xcodeproj/project.pbxproj @@ -50,6 +50,10 @@ B1000005 /* DisplayStatsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000005 /* DisplayStatsAggregator.swift */; }; B1000006 /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000006 /* SyncCoordinator.swift */; }; B1000007 /* SyncSettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000007 /* SyncSettingsWindowController.swift */; }; + C1100001 /* HourlyStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2100001 /* HourlyStats.swift */; }; + C1100002 /* HourlyStatsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2100002 /* HourlyStatsWindowController.swift */; }; + C1100003 /* HourlyStatsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2100003 /* HourlyStatsViewController.swift */; }; + C1100004 /* HourlyStatsChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2100004 /* HourlyStatsChartView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -123,6 +127,10 @@ A3000001 /* KeyStats.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyStats.app; sourceTree = BUILT_PRODUCTS_DIR; }; A3000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A3000003 /* KeyStats.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyStats.entitlements; sourceTree = ""; }; + C2100001 /* HourlyStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourlyStats.swift; sourceTree = ""; }; + C2100002 /* HourlyStatsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourlyStatsWindowController.swift; sourceTree = ""; }; + C2100003 /* HourlyStatsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourlyStatsViewController.swift; sourceTree = ""; }; + C2100004 /* HourlyStatsChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourlyStatsChartView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -183,6 +191,10 @@ 9A20000C /* RemoteEventProcessor.swift */, 9A20000D /* HelperMigrationPresenter.swift */, A2000032 /* StatsModels.swift */, + C2100004 /* HourlyStatsChartView.swift */, + C2100003 /* HourlyStatsViewController.swift */, + C2100002 /* HourlyStatsWindowController.swift */, + C2100001 /* HourlyStats.swift */, A2000004 /* MenuBarController.swift */, A2000033 /* KPSDetailView.swift */, A2000005 /* StatsPopoverViewController.swift */, @@ -376,6 +388,10 @@ A1000003 /* StatsManager.swift in Sources */, A1000022 /* AppStats.swift in Sources */, A1000032 /* StatsModels.swift in Sources */, + C1100004 /* HourlyStatsChartView.swift in Sources */, + C1100003 /* HourlyStatsViewController.swift in Sources */, + C1100002 /* HourlyStatsWindowController.swift in Sources */, + C1100001 /* HourlyStats.swift in Sources */, A1000004 /* MenuBarController.swift in Sources */, A1000033 /* KPSDetailView.swift in Sources */, A1000005 /* StatsPopoverViewController.swift in Sources */, diff --git a/KeyStats/HourlyStats.swift b/KeyStats/HourlyStats.swift new file mode 100644 index 0000000..d08d17a --- /dev/null +++ b/KeyStats/HourlyStats.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Local aggregate counters only. The recording time zone stays fixed so travel +/// cannot move previously recorded counts into a different hour or date. +struct HourlyStats: Codable { + struct Counts: Codable, Equatable { + var keys = 0 + var clicks = 0 + } + + struct Point { + let date: Date + let counts: Counts? + } + + let startedAt: Date + let timeZoneIdentifier: String + private var buckets: [String: Counts] = [:] + + init(startedAt: Date = Date(), timeZone: TimeZone = .current) { + self.startedAt = startedAt + timeZoneIdentifier = timeZone.identifier + } + + var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: timeZoneIdentifier) ?? .current + return calendar + } + + mutating func record(keys: Int = 0, clicks: Int = 0, at date: Date = Date()) { + guard date >= startedAt, + let hour = calendar.dateInterval(of: .hour, for: date) else { return } + let key = bucketKey(hour.start) + var counts = buckets[key] ?? Counts() + counts.keys = saturatingNonnegativeSum([counts.keys, keys]) + counts.clicks = saturatingNonnegativeSum([counts.clicks, clicks]) + buckets[key] = counts + } + + /// Includes the current, incomplete hour and the 23 preceding hourly buckets. + func points(on date: Date, recent24Hours: Bool, now: Date = Date()) -> [Point] { + let start: Date + let end: Date + if recent24Hours { + guard let hour = calendar.dateInterval(of: .hour, for: now) else { return [] } + start = hour.start.addingTimeInterval(-23 * 3600) + end = hour.end + } else { + guard let day = calendar.dateInterval(of: .day, for: date) else { return [] } + start = day.start + end = day.end + } + var result: [Point] = [] + var hour = start + while hour < end { + let available = hour <= now && hour.addingTimeInterval(3600) > startedAt + result.append(Point(date: hour, counts: available ? (buckets[bucketKey(hour)] ?? Counts()) : nil)) + hour = hour.addingTimeInterval(3600) + } + return result + } + + private func bucketKey(_ date: Date) -> String { + String(Int64(date.timeIntervalSince1970)) + } +} diff --git a/KeyStats/HourlyStatsChartView.swift b/KeyStats/HourlyStatsChartView.swift new file mode 100644 index 0000000..b12ca19 --- /dev/null +++ b/KeyStats/HourlyStatsChartView.swift @@ -0,0 +1,151 @@ +import Cocoa + +/// Two aggregate series, with gaps for hours outside the recording period. +final class HourlyStatsChartView: NSView { + var points: [HourlyStats.Point] = [] { + didSet { + needsDisplay = true + setAccessibilityValue(points.map { point in + let counts = point.counts.map { "\($0.keys) / \($0.clicks)" } + ?? NSLocalizedString("hourly.unavailable", comment: "") + return "\(hourLabel(point.date)): \(counts)" + }.joined(separator: "; ")) + } + } + var timeZone = TimeZone.current + var onHover: ((HourlyStats.Point?) -> Void)? + private var hoverIndex: Int? + private var area: NSTrackingArea? + + var hoveredPoint: HourlyStats.Point? { + guard let hoverIndex, points.indices.contains(hoverIndex) else { return nil } + return points[hoverIndex] + } + + override init(frame: NSRect) { + super.init(frame: frame) + setAccessibilityElement(true) + setAccessibilityRole(.image) + setAccessibilityLabel(NSLocalizedString("hourly.chartAccessibility", comment: "")) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let area { removeTrackingArea(area) } + let area = NSTrackingArea(rect: .zero, options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil) + addTrackingArea(area) + self.area = area + } + + override func mouseMoved(with event: NSEvent) { + let position = convert(event.locationInWindow, from: nil) + guard plotRect.contains(position), !points.isEmpty else { clearHover(); return } + let fraction = (position.x - plotRect.minX) / plotRect.width + hoverIndex = min(points.count - 1, max(0, Int((fraction * CGFloat(points.count - 1)).rounded()))) + needsDisplay = true + onHover?(hoveredPoint) + } + + override func mouseExited(with event: NSEvent) { clearHover() } + + func clearHover() { + hoverIndex = nil + needsDisplay = true + onHover?(nil) + } + + func hourLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + private var plotRect: NSRect { + NSRect(x: 52, y: 32, width: max(1, bounds.width - 72), height: max(1, bounds.height - 64)) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + NSColor.controlBackgroundColor.withAlphaComponent(0.5).setFill() + NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10).fill() + let rect = plotRect + let maximum = max(4, points.reduce(0) { max($0, max($1.counts?.keys ?? 0, $1.counts?.clicks ?? 0)) }) + for step in 0...4 { + let y = rect.minY + rect.height * CGFloat(step) / 4 + stroke(from: NSPoint(x: rect.minX, y: y), to: NSPoint(x: rect.maxX, y: y), color: .separatorColor, width: 0.5) + label(String(format: "%.0f", Double(maximum) * Double(step) / 4), + at: NSPoint(x: 4, y: y - 6), color: .secondaryLabelColor) + } + let keyLabel = NSLocalizedString("history.metric.keys", comment: "") + let clickLabel = NSLocalizedString("history.metric.clicks", comment: "") + label("● " + keyLabel, at: NSPoint(x: rect.minX, y: bounds.height - 22), color: .systemBlue) + label("◆ " + clickLabel, at: NSPoint(x: rect.minX + 110, y: bounds.height - 22), color: .systemOrange) + guard !points.isEmpty else { return } + for index in points.indices where index % 3 == 0 || index == points.count - 1 { + // Avoid colliding with the last tick on 23/25-hour daylight-saving days. + if index != points.count - 1 && points.count - 1 - index < 2 { continue } + label(hourLabel(points[index].date), at: NSPoint(x: x(index) - 16, y: 10), color: .secondaryLabelColor) + } + drawSeries(color: .systemBlue, maximum: maximum, clicks: false) + drawSeries(color: .systemOrange, maximum: maximum, clicks: true) + if points.allSatisfy({ $0.counts == nil }) { + label(NSLocalizedString("hourly.empty", comment: ""), at: NSPoint(x: rect.minX + 16, y: rect.midY), color: .secondaryLabelColor) + } + if let hoverIndex, points.indices.contains(hoverIndex) { + stroke(from: NSPoint(x: x(hoverIndex), y: rect.minY), to: NSPoint(x: x(hoverIndex), y: rect.maxY), color: .secondaryLabelColor, width: 1) + } + } + + private func x(_ index: Int) -> CGFloat { + plotRect.minX + plotRect.width * CGFloat(index) / CGFloat(max(1, points.count - 1)) + } + + private func drawSeries(color: NSColor, maximum: Int, clicks: Bool) { + let path = NSBezierPath() + var previous: NSPoint? + for (index, point) in points.enumerated() { + guard let counts = point.counts else { previous = nil; continue } + let value = clicks ? counts.clicks : counts.keys + let position = NSPoint(x: x(index), y: plotRect.minY + plotRect.height * CGFloat(value) / CGFloat(maximum)) + if previous != nil { path.line(to: position) } else { path.move(to: position) } + color.setFill() + if clicks { + let diamond = NSBezierPath() + diamond.move(to: NSPoint(x: position.x, y: position.y + 3)) + diamond.line(to: NSPoint(x: position.x + 3, y: position.y)) + diamond.line(to: NSPoint(x: position.x, y: position.y - 3)) + diamond.line(to: NSPoint(x: position.x - 3, y: position.y)) + diamond.close() + diamond.fill() + } else { + NSBezierPath(ovalIn: NSRect(x: position.x - 2.5, y: position.y - 2.5, width: 5, height: 5)).fill() + } + previous = position + } + color.setStroke() + path.lineWidth = 2 + if clicks { path.setLineDash([5, 3], count: 2, phase: 0) } + path.stroke() + } + + private func stroke(from start: NSPoint, to end: NSPoint, color: NSColor, width: CGFloat) { + let path = NSBezierPath() + path.move(to: start) + path.line(to: end) + path.lineWidth = width + color.setStroke() + path.stroke() + } + + private func label(_ text: String, at point: NSPoint, color: NSColor) { + text.draw(at: point, withAttributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular), .foregroundColor: color]) + } +} diff --git a/KeyStats/HourlyStatsViewController.swift b/KeyStats/HourlyStatsViewController.swift new file mode 100644 index 0000000..5f491ed --- /dev/null +++ b/KeyStats/HourlyStatsViewController.swift @@ -0,0 +1,178 @@ +import Cocoa + +final class HourlyStatsViewController: NSViewController { + private let datePicker = NSDatePicker() + private let previousButton = NSButton() + private let nextButton = NSButton() + private let mode = NSSegmentedControl() + private let summary = NSTextField(labelWithString: "") + private let detail = NSTextField(labelWithString: "") + private let note = NSTextField(wrappingLabelWithString: "") + private let chart = HourlyStatsChartView() + private var refreshTimer: Timer? + private var lastDay: Date? + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 760, height: 490)) + } + + override func viewDidLoad() { + super.viewDidLoad() + let title = NSTextField(labelWithString: NSLocalizedString("hourly.title", comment: "")) + title.font = .systemFont(ofSize: 22, weight: .semibold) + mode.segmentCount = 2 + mode.setLabel(NSLocalizedString("hourly.byDate", comment: ""), forSegment: 0) + mode.setLabel(NSLocalizedString("hourly.recent", comment: ""), forSegment: 1) + mode.selectedSegment = 0 + mode.target = self + mode.action = #selector(modeChanged) + datePicker.datePickerElements = [.yearMonthDay] + datePicker.datePickerStyle = .textFieldAndStepper + datePicker.dateValue = Date() + datePicker.target = self + datePicker.action = #selector(dateChanged) + datePicker.setAccessibilityLabel(NSLocalizedString("hourly.byDate", comment: "")) + configure(previousButton, title: "‹", label: "hourly.previous", action: #selector(previousDay)) + configure(nextButton, title: "›", label: "hourly.next", action: #selector(nextDay)) + let controls = NSStackView(views: [mode, previousButton, datePicker, nextButton]) + controls.spacing = 10 + summary.font = .systemFont(ofSize: 15, weight: .medium) + detail.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + detail.textColor = .secondaryLabelColor + note.font = .systemFont(ofSize: 11) + note.textColor = .secondaryLabelColor + for child in [title, controls, summary, chart, detail, note] { + child.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(child) + } + NSLayoutConstraint.activate([ + title.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + title.topAnchor.constraint(equalTo: view.topAnchor, constant: 24), + controls.leadingAnchor.constraint(equalTo: title.leadingAnchor), + controls.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 20), + controls.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + summary.leadingAnchor.constraint(equalTo: title.leadingAnchor), + summary.topAnchor.constraint(equalTo: controls.bottomAnchor, constant: 20), + summary.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + chart.leadingAnchor.constraint(equalTo: title.leadingAnchor), + chart.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + chart.topAnchor.constraint(equalTo: summary.bottomAnchor, constant: 12), + chart.bottomAnchor.constraint(equalTo: detail.topAnchor, constant: -10), + detail.leadingAnchor.constraint(equalTo: title.leadingAnchor), + detail.trailingAnchor.constraint(equalTo: chart.trailingAnchor), + detail.bottomAnchor.constraint(equalTo: note.topAnchor, constant: -12), + note.leadingAnchor.constraint(equalTo: title.leadingAnchor), + note.trailingAnchor.constraint(equalTo: chart.trailingAnchor), + note.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20) + ]) + chart.onHover = { [weak self] point in self?.showDetail(point) } + refresh() + } + + override func viewWillAppear() { + super.viewWillAppear() + refresh() + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + self?.refresh() + } + } + + override func viewWillDisappear() { + super.viewWillDisappear() + refreshTimer?.invalidate() + refreshTimer = nil + } + + deinit { refreshTimer?.invalidate() } + + func refresh() { + guard isViewLoaded else { return } + let stats = StatsManager.shared.hourlyStatsSnapshot() + let now = Date() + let today = stats.calendar.startOfDay(for: now) + // Follow today across midnight only when the user was already viewing today. + if let lastDay, lastDay != today, stats.calendar.isDate(datePicker.dateValue, inSameDayAs: lastDay) { + datePicker.dateValue = today + } + lastDay = today + datePicker.timeZone = stats.calendar.timeZone + datePicker.maxDate = now + let recent = mode.selectedSegment == 1 + datePicker.isEnabled = !recent + previousButton.isEnabled = !recent + nextButton.isEnabled = !recent && stats.calendar.startOfDay(for: datePicker.dateValue) < today + let points = stats.points(on: datePicker.dateValue, recent24Hours: recent, now: now) + chart.timeZone = stats.calendar.timeZone + chart.points = points + let recorded = points.compactMap(\.counts) + if recorded.isEmpty { + summary.stringValue = NSLocalizedString("hourly.empty", comment: "") + } else { + let keys = saturatingNonnegativeSum(recorded.map(\.keys)) + let clicks = saturatingNonnegativeSum(recorded.map(\.clicks)) + let peak = points.filter { $0.counts != nil }.max { + saturatingNonnegativeSum([$0.counts?.keys ?? 0, $0.counts?.clicks ?? 0]) < + saturatingNonnegativeSum([$1.counts?.keys ?? 0, $1.counts?.clicks ?? 0]) + } + let peakText = (keys > 0 || clicks > 0) ? peak.map { chart.hourLabel($0.date) } ?? "—" : "—" + summary.stringValue = String(format: NSLocalizedString("hourly.summary", comment: ""), keys, clicks, peakText) + } + let formatter = DateFormatter() + formatter.timeZone = stats.calendar.timeZone + formatter.dateStyle = .medium + formatter.timeStyle = .short + note.stringValue = String(format: NSLocalizedString("hourly.note", comment: ""), + formatter.string(from: stats.startedAt), stats.timeZoneIdentifier) + showDetail(chart.hoveredPoint) + } + + private func configure(_ button: NSButton, title: String, label: String, action: Selector) { + button.title = title + button.bezelStyle = .rounded + button.target = self + button.action = action + button.toolTip = NSLocalizedString(label, comment: "") + button.setAccessibilityLabel(NSLocalizedString(label, comment: "")) + } + + private func showDetail(_ point: HourlyStats.Point?) { + guard let point else { + detail.stringValue = NSLocalizedString("hourly.hover", comment: "") + return + } + let formatter = DateFormatter() + formatter.timeZone = chart.timeZone + formatter.setLocalizedDateFormatFromTemplate("MMMd HH:mm z") + let time = formatter.string(from: point.date) + if let counts = point.counts { + detail.stringValue = String(format: NSLocalizedString("hourly.detail", comment: ""), time, counts.keys, counts.clicks) + } else { + detail.stringValue = time + " · " + NSLocalizedString("hourly.unavailable", comment: "") + } + } + + @objc private func modeChanged() { + AppDelegate.trackClick("hourly_mode", properties: ["mode": mode.selectedSegment == 0 ? "date" : "recent_24_hours"]) + chart.clearHover() + refresh() + } + + @objc private func dateChanged() { + AppDelegate.trackClick("hourly_date") + chart.clearHover() + refresh() + } + + private func moveDay(_ offset: Int) { + let calendar = StatsManager.shared.hourlyStatsSnapshot().calendar + guard let date = calendar.date(byAdding: .day, value: offset, to: datePicker.dateValue) else { return } + datePicker.dateValue = min(date, Date()) + AppDelegate.trackClick("hourly_day", properties: ["direction": offset < 0 ? "previous" : "next"]) + chart.clearHover() + refresh() + } + + @objc private func previousDay() { moveDay(-1) } + @objc private func nextDay() { moveDay(1) } +} diff --git a/KeyStats/HourlyStatsWindowController.swift b/KeyStats/HourlyStatsWindowController.swift new file mode 100644 index 0000000..541a800 --- /dev/null +++ b/KeyStats/HourlyStatsWindowController.swift @@ -0,0 +1,26 @@ +import Cocoa + +final class HourlyStatsWindowController: NSWindowController { + static let shared = HourlyStatsWindowController() + + private init() { + let controller = HourlyStatsViewController() + let window = NSWindow(contentViewController: controller) + window.title = NSLocalizedString("hourly.title", comment: "") + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.setContentSize(NSSize(width: 760, height: 490)) + window.minSize = NSSize(width: 660, height: 440) + window.isReleasedWhenClosed = false + window.center() + super.init(window: window) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func show() { + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + (contentViewController as? HourlyStatsViewController)?.refresh() + AppDelegate.trackPageView("hourly_stats") + } +} diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index 034acab..d02f6de 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -96,6 +96,8 @@ class StatsManager { private let userDefaults = UserDefaults.standard private let statsKey = "dailyStats" private let historyKey = "dailyStatsHistory" + private let hourlyStatsKey = "hourlyStats.v1" + private var hourlyStats = HourlyStats() private let showKeyPressesKey = "showKeyPressesInMenuBar" private let showMouseClicksKey = "showMouseClicksInMenuBar" private let minimalMenuBarModeKey = "minimalMenuBarMode" @@ -318,6 +320,10 @@ class StatsManager { let today = calendar.startOfDay(for: Date()) currentStats = DailyStats(date: today) history = normalizedHistory(loadHistory()) + if let data = userDefaults.data(forKey: hourlyStatsKey), + let stored = try? JSONDecoder().decode(HourlyStats.self, from: data) { + hourlyStats = stored + } // 优先加载 dailyStats;如果缺失或不是今天,回退到 history 里的今天数据。 let loadedCurrent = loadStats().map(normalizedDailyStats) @@ -378,6 +384,7 @@ class StatsManager { statsStateLock.lock() ensureCurrentDayLocked() currentStats.keyPresses += 1 + hourlyStats.record(keys: 1) if let keyName = keyName { let canonicalName = canonicalKeyName(keyName) if !canonicalName.isEmpty { @@ -401,6 +408,7 @@ class StatsManager { statsStateLock.lock() ensureCurrentDayLocked() currentStats.leftClicks += 1 + hourlyStats.record(clicks: 1) if let appIdentity = appIdentity { updateAppStatsLocked(for: appIdentity) { stats in stats.recordLeftClick() @@ -418,6 +426,7 @@ class StatsManager { statsStateLock.lock() ensureCurrentDayLocked() currentStats.rightClicks += 1 + hourlyStats.record(clicks: 1) if let appIdentity = appIdentity { updateAppStatsLocked(for: appIdentity) { stats in stats.recordRightClick() @@ -435,6 +444,7 @@ class StatsManager { statsStateLock.lock() ensureCurrentDayLocked() currentStats.sideBackClicks += 1 + hourlyStats.record(clicks: 1) if let appIdentity = appIdentity { updateAppStatsLocked(for: appIdentity) { stats in stats.recordSideBackClick() @@ -452,6 +462,7 @@ class StatsManager { statsStateLock.lock() ensureCurrentDayLocked() currentStats.sideForwardClicks += 1 + hourlyStats.record(clicks: 1) if let appIdentity = appIdentity { updateAppStatsLocked(for: appIdentity) { stats in stats.recordSideForwardClick() @@ -837,6 +848,7 @@ class StatsManager { private func saveStats() { statsStateLock.lock() let statsSnapshot = currentStats + let hourlySnapshot = hourlyStats let calendar = Calendar.current let normalizedDate = calendar.startOfDay(for: statsSnapshot.date) let key = dateFormatter.string(from: normalizedDate) @@ -846,6 +858,9 @@ class StatsManager { let historySnapshot = history statsStateLock.unlock() + if let encoded = try? JSONEncoder().encode(hourlySnapshot) { + userDefaults.set(encoded, forKey: hourlyStatsKey) + } if let encoded = try? JSONEncoder().encode(statsSnapshot) { userDefaults.set(encoded, forKey: statsKey) } @@ -854,6 +869,13 @@ class StatsManager { } } + /// A value snapshot for the local hourly view; sync does not modify these counters. + func hourlyStatsSnapshot() -> HourlyStats { + statsStateLock.lock() + defer { statsStateLock.unlock() } + return hourlyStats + } + private func loadStats() -> DailyStats? { guard let data = userDefaults.data(forKey: statsKey), let stats = try? JSONDecoder().decode(DailyStats.self, from: data) else { diff --git a/KeyStats/StatsModels.swift b/KeyStats/StatsModels.swift index c66e3ae..dbbc7dc 100644 --- a/KeyStats/StatsModels.swift +++ b/KeyStats/StatsModels.swift @@ -26,7 +26,7 @@ func formatMenuBarCompactNumber(_ number: Int) -> String { return String(format: "%.\(decimalPlaces)f%@", truncatedValue, units[unitIndex]) } -private func saturatingNonnegativeSum(_ values: [Int]) -> Int { +func saturatingNonnegativeSum(_ values: [Int]) -> Int { values.reduce(0) { total, value in let nonnegative = max(0, value) let (sum, overflow) = total.addingReportingOverflow(nonnegative) diff --git a/KeyStats/StatsPopoverViewController.swift b/KeyStats/StatsPopoverViewController.swift index 86bb1ef..6a4a042 100644 --- a/KeyStats/StatsPopoverViewController.swift +++ b/KeyStats/StatsPopoverViewController.swift @@ -292,6 +292,16 @@ class StatsPopoverViewController: NSViewController { historyTitleLabel = createLabel(text: NSLocalizedString("section.history", comment: ""), fontSize: 14, weight: .semibold) historyTitleLabel.translatesAutoresizingMaskIntoConstraints = false containerView.addSubview(historyTitleLabel) + let hourlyButton = NSButton(title: NSLocalizedString("hourly.title", comment: ""), target: self, action: #selector(showHourlyStats)) + hourlyButton.bezelStyle = .rounded + hourlyButton.controlSize = .small + hourlyButton.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(hourlyButton) + NSLayoutConstraint.activate([ + hourlyButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), + hourlyButton.centerYAnchor.constraint(equalTo: historyTitleLabel.centerYAnchor), + hourlyButton.leadingAnchor.constraint(greaterThanOrEqualTo: historyTitleLabel.trailingAnchor, constant: 8) + ]) // 时间范围 rangeControl = NSSegmentedControl(labels: [ @@ -876,6 +886,12 @@ class StatsPopoverViewController: NSViewController { view.window?.performClose(nil) } + @objc private func showHourlyStats() { + AppDelegate.trackClick("open_hourly_stats") + HourlyStatsWindowController.shared.show() + view.window?.performClose(nil) + } + @objc private func showAllTimeStats() { AppDelegate.trackClick("open_all_time_stats") AllTimeStatsWindowController.shared.show() diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index cdc45f3..611d2b7 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -356,3 +356,17 @@ "stats.reset.local.message" = "Only today's statistics on this Mac will be reset. Other devices are unaffected, and the zero snapshot will sync with a higher revision."; "button.cancel" = "Cancel"; "button.confirm" = "Confirm"; + +// Hourly trends +"hourly.title" = "Hourly Trends"; +"hourly.byDate" = "Select Date"; +"hourly.recent" = "Last 24 Hours"; +"hourly.previous" = "Previous day"; +"hourly.next" = "Next day"; +"hourly.empty" = "No hourly data for this date"; +"hourly.unavailable" = "No hourly data"; +"hourly.summary" = "Recorded totals: %ld keys · %ld clicks · Busiest hour: %@"; +"hourly.note" = "Local data · Recording since %@ · %@\nThe first and current hours may be incomplete. Last 24 Hours includes the current hour and the preceding 23 hours."; +"hourly.hover" = "Hover over the chart to see hourly counts."; +"hourly.detail" = "%@ · %ld keys · %ld clicks"; +"hourly.chartAccessibility" = "Hourly counts: keys (solid blue), clicks (dashed orange). Each hour lists keys / clicks."; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index aa0d1a1..d7e88c1 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -356,3 +356,17 @@ "stats.reset.local.message" = "只会重置本机今天的统计,其他设备不受影响;下一次同步会用更高版本传播零值快照。"; "button.cancel" = "取消"; "button.confirm" = "确认"; + +// Hourly trends +"hourly.title" = "小时趋势"; +"hourly.byDate" = "选择日期"; +"hourly.recent" = "最近 24 小时"; +"hourly.previous" = "前一天"; +"hourly.next" = "后一天"; +"hourly.empty" = "该日期暂无小时数据"; +"hourly.unavailable" = "暂无小时数据"; +"hourly.summary" = "已记录合计:按键 %ld · 点击 %ld · 最活跃时段:%@"; +"hourly.note" = "本机数据 · 从 %@ 开始记录 · %@\n起始小时和当前小时可能不完整。「最近 24 小时」包含当前小时及之前的 23 个小时。"; +"hourly.hover" = "将鼠标移至折线图,查看每小时的按键和点击次数。"; +"hourly.detail" = "%@ · 按键 %ld · 点击 %ld"; +"hourly.chartAccessibility" = "每小时次数:按键为蓝色实线,点击为橙色虚线。每个小时依次列出按键数和点击数。"; diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings index 9fd8e8b..0094364 100644 --- a/KeyStats/zh-Hant.lproj/Localizable.strings +++ b/KeyStats/zh-Hant.lproj/Localizable.strings @@ -356,3 +356,17 @@ "stats.reset.local.message" = "只會重置本機今天的統計,其他設備不受影響;下一次同步會用更高版本傳播零值快照。"; "button.cancel" = "取消"; "button.confirm" = "確認"; + +// Hourly trends +"hourly.title" = "小時趨勢"; +"hourly.byDate" = "選擇日期"; +"hourly.recent" = "最近 24 小時"; +"hourly.previous" = "前一天"; +"hourly.next" = "後一天"; +"hourly.empty" = "該日期暫無小時資料"; +"hourly.unavailable" = "暫無小時資料"; +"hourly.summary" = "已記錄合計:按鍵 %ld · 點擊 %ld · 最活躍時段:%@"; +"hourly.note" = "本機資料 · 從 %@ 開始記錄 · %@\n起始小時和目前小時可能不完整。「最近 24 小時」包含目前小時及之前的 23 個小時。"; +"hourly.hover" = "將滑鼠移至折線圖,查看每小時的按鍵和點擊次數。"; +"hourly.detail" = "%@ · 按鍵 %ld · 點擊 %ld"; +"hourly.chartAccessibility" = "每小時次數:按鍵為藍色實線,點擊為橙色虛線。每個小時依次列出按鍵數和點擊數。"; diff --git a/KeyStatsTests/HourlyStatsTests.swift b/KeyStatsTests/HourlyStatsTests.swift new file mode 100644 index 0000000..1ef5aac --- /dev/null +++ b/KeyStatsTests/HourlyStatsTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import KeyStatsCore + +final class HourlyStatsTests: XCTestCase { + private func date(_ value: String) -> Date { + ISO8601DateFormatter().date(from: value)! + } + + func testUpgradeDoesNotBackfillUnknownHoursAndFutureHoursRemainEmpty() { + let start = date("2026-09-10T10:30:00Z") + let stats = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + let points = stats.points(on: start, recent24Hours: false, now: date("2026-09-10T12:15:00Z")) + XCTAssertEqual(points.count, 24) + XCTAssertTrue(points.prefix(10).allSatisfy { $0.counts == nil }) + XCTAssertEqual(points[10].counts, HourlyStats.Counts()) + XCTAssertEqual(points[12].counts, HourlyStats.Counts()) + XCTAssertTrue(points.suffix(11).allSatisfy { $0.counts == nil }) + XCTAssertTrue(stats.points(on: date("2026-09-09T00:00:00Z"), recent24Hours: false, now: start).allSatisfy { $0.counts == nil }) + } + + func testHourAndMidnightBoundariesSurvivePersistenceAndFurtherRecording() throws { + let start = date("2026-09-10T22:00:00Z") + var stats = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + stats.record(keys: 2, at: date("2026-09-10T22:59:59Z")) + stats.record(clicks: 3, at: date("2026-09-10T23:00:00Z")) + let midnight = date("2026-09-11T00:00:00Z") + stats.record(keys: 4, at: midnight) + var restored = try JSONDecoder().decode(HourlyStats.self, from: JSONEncoder().encode(stats)) + restored.record(clicks: 1, at: midnight) + XCTAssertEqual(restored.startedAt, start) + let yesterday = restored.points(on: start, recent24Hours: false, now: midnight) + XCTAssertEqual(yesterday[22].counts?.keys, 2) + XCTAssertEqual(yesterday[23].counts?.clicks, 3) + let today = restored.points(on: midnight, recent24Hours: false, now: midnight) + XCTAssertEqual(today[0].counts, HourlyStats.Counts(keys: 4, clicks: 1)) + XCTAssertNil(today[1].counts) + } + + func testRecentWindowIncludesCurrentHourAndPreceding23Hours() { + let start = date("2026-09-08T00:00:00Z") + let stats = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + let points = stats.points(on: start, recent24Hours: true, now: date("2026-09-10T12:45:00Z")) + XCTAssertEqual(points.count, 24) + XCTAssertEqual(points.first?.date, date("2026-09-09T13:00:00Z")) + XCTAssertEqual(points.last?.date, date("2026-09-10T12:00:00Z")) + } + + func testRepeatedDaylightSavingHourHasDistinctCounts() { + var stats = HourlyStats(startedAt: date("2026-10-31T00:00:00Z"), timeZone: TimeZone(identifier: "America/New_York")!) + stats.record(keys: 2, at: date("2026-11-01T05:30:00Z")) + stats.record(keys: 3, at: date("2026-11-01T06:30:00Z")) + let points = stats.points(on: date("2026-11-01T12:00:00Z"), recent24Hours: false, now: date("2026-11-02T12:00:00Z")) + XCTAssertEqual(points.count, 25) + XCTAssertEqual(points[1].counts?.keys, 2) + XCTAssertEqual(points[2].counts?.keys, 3) + XCTAssertEqual(stats.calendar.component(.hour, from: points[1].date), 1) + XCTAssertEqual(stats.calendar.component(.hour, from: points[2].date), 1) + } + + func testSpringDaylightSavingDayHas23Hours() { + let stats = HourlyStats(startedAt: date("2026-03-01T00:00:00Z"), timeZone: TimeZone(identifier: "America/New_York")!) + let points = stats.points(on: date("2026-03-08T12:00:00Z"), recent24Hours: false, now: date("2026-03-09T12:00:00Z")) + XCTAssertEqual(points.count, 23) + XCTAssertFalse(points.contains { stats.calendar.component(.hour, from: $0.date) == 2 }) + } + + func testHalfHourTimeZoneUsesLocalHourBoundaryAndRetainsZone() throws { + let start = date("2026-09-10T00:00:00Z") + var stats = HourlyStats(startedAt: start, timeZone: TimeZone(identifier: "Asia/Kolkata")!) + stats.record(keys: 1, at: date("2026-09-10T00:29:59Z")) + stats.record(clicks: 1, at: date("2026-09-10T00:30:00Z")) + let restored = try JSONDecoder().decode(HourlyStats.self, from: JSONEncoder().encode(stats)) + let points = restored.points(on: start, recent24Hours: false, now: date("2026-09-10T01:00:00Z")) + XCTAssertEqual(restored.calendar.timeZone.identifier, "Asia/Kolkata") + XCTAssertEqual(points[5].counts?.keys, 1) + XCTAssertEqual(points[6].counts?.clicks, 1) + } + + func testCounterOverflowAndEventsBeforeRecordingStart() { + let start = date("2026-09-10T00:00:00Z") + var stats = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + stats.record(keys: 100, at: start.addingTimeInterval(-1)) + stats.record(keys: Int.max, clicks: -1, at: start) + stats.record(keys: 1, at: start) + let points = stats.points(on: start, recent24Hours: false, now: start) + XCTAssertEqual(points[0].counts?.keys, Int.max) + XCTAssertEqual(points[0].counts?.clicks, 0) + } +} diff --git a/Package.swift b/Package.swift index b980811..6bc55cd 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,9 @@ let package = Package( "KeyStats.entitlements", "NotificationManager.swift", "StatsManager.swift", + "HourlyStatsWindowController.swift", + "HourlyStatsViewController.swift", + "HourlyStatsChartView.swift", "HoverIconButton.swift", "MouseDistanceCalibrationViewController.swift", "ActivityHeatmapView.swift", @@ -52,6 +55,7 @@ let package = Package( sources: [ "AppStats.swift", "StatsModels.swift", + "HourlyStats.swift", "UpdateCheckCoordinator.swift", "Sync/SyncModels.swift", "Sync/SyncCrypto.swift", @@ -64,7 +68,7 @@ let package = Package( name: "KeyStatsCoreTests", dependencies: ["KeyStatsCore"], path: "KeyStatsTests", - sources: ["AppStatsTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift"] + sources: ["HourlyStatsTests.swift", "AppStatsTests.swift", "StatsModelsTests.swift", "UpdateCheckCoordinatorTests.swift", "SyncCoreTests.swift"] ) ] ) From 8441d3adfaf00c5b6fe28eb22ac1fad37f529fbd Mon Sep 17 00:00:00 2001 From: pipizhu Date: Thu, 10 Sep 2026 18:25:52 +0800 Subject: [PATCH 2/8] fix(macos): match hourly date navigation to keyboard heatmap --- KeyStats/HourlyStatsViewController.swift | 136 ++++++++++++++++--- KeyStats/KeyboardHeatmapViewController.swift | 8 +- 2 files changed, 119 insertions(+), 25 deletions(-) diff --git a/KeyStats/HourlyStatsViewController.swift b/KeyStats/HourlyStatsViewController.swift index 5f491ed..20d4ffc 100644 --- a/KeyStats/HourlyStatsViewController.swift +++ b/KeyStats/HourlyStatsViewController.swift @@ -1,7 +1,25 @@ import Cocoa final class HourlyStatsViewController: NSViewController { - private let datePicker = NSDatePicker() + private let datePickerButton = NSButton() + private let backToTodayButton = NSButton() + private var selectedDate = Date() + private lazy var datePickerController: HeatmapDatePickerPopoverViewController = { + let controller = HeatmapDatePickerPopoverViewController() + controller.onDateSelected = { [weak self] date in + guard let self else { return } + self.selectedDate = date + self.datePickerPopover.performClose(nil) + self.dateChanged() + } + return controller + }() + private lazy var datePickerPopover: NSPopover = { + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = datePickerController + return popover + }() private let previousButton = NSButton() private let nextButton = NSButton() private let mode = NSSegmentedControl() @@ -26,16 +44,31 @@ final class HourlyStatsViewController: NSViewController { mode.selectedSegment = 0 mode.target = self mode.action = #selector(modeChanged) - datePicker.datePickerElements = [.yearMonthDay] - datePicker.datePickerStyle = .textFieldAndStepper - datePicker.dateValue = Date() - datePicker.target = self - datePicker.action = #selector(dateChanged) - datePicker.setAccessibilityLabel(NSLocalizedString("hourly.byDate", comment: "")) - configure(previousButton, title: "‹", label: "hourly.previous", action: #selector(previousDay)) - configure(nextButton, title: "›", label: "hourly.next", action: #selector(nextDay)) - let controls = NSStackView(views: [mode, previousButton, datePicker, nextButton]) - controls.spacing = 10 + datePickerButton.bezelStyle = .rounded + datePickerButton.controlSize = .regular + datePickerButton.font = .monospacedDigitSystemFont(ofSize: 14, weight: .semibold) + datePickerButton.contentTintColor = .labelColor + datePickerButton.target = self + datePickerButton.action = #selector(toggleDatePicker) + datePickerButton.setContentCompressionResistancePriority(.required, for: .horizontal) + datePickerButton.setContentHuggingPriority(.required, for: .horizontal) + datePickerButton.setAccessibilityLabel(NSLocalizedString("hourly.byDate", comment: "")) + backToTodayButton.title = NSLocalizedString("keyboardHeatmap.backToToday", comment: "") + backToTodayButton.bezelStyle = .rounded + backToTodayButton.controlSize = .regular + backToTodayButton.target = self + backToTodayButton.action = #selector(backToToday) + backToTodayButton.setContentCompressionResistancePriority(.required, for: .horizontal) + backToTodayButton.setContentHuggingPriority(.required, for: .horizontal) + configure(previousButton, symbol: "chevron.left", label: "hourly.previous", action: #selector(previousDay)) + configure(nextButton, symbol: "chevron.right", label: "hourly.next", action: #selector(nextDay)) + let dateControls = NSStackView(views: [previousButton, datePickerButton, nextButton, backToTodayButton]) + dateControls.alignment = .centerY + dateControls.spacing = 8 + dateControls.setCustomSpacing(10, after: nextButton) + let controls = NSStackView(views: [mode, dateControls]) + controls.alignment = .centerY + controls.spacing = 20 summary.font = .systemFont(ofSize: 15, weight: .medium) detail.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) detail.textColor = .secondaryLabelColor @@ -46,6 +79,7 @@ final class HourlyStatsViewController: NSViewController { view.addSubview(child) } NSLayoutConstraint.activate([ + datePickerButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 78), title.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), title.topAnchor.constraint(equalTo: view.topAnchor, constant: 24), controls.leadingAnchor.constraint(equalTo: title.leadingAnchor), @@ -92,17 +126,17 @@ final class HourlyStatsViewController: NSViewController { let now = Date() let today = stats.calendar.startOfDay(for: now) // Follow today across midnight only when the user was already viewing today. - if let lastDay, lastDay != today, stats.calendar.isDate(datePicker.dateValue, inSameDayAs: lastDay) { - datePicker.dateValue = today + if let lastDay, lastDay != today, stats.calendar.isDate(selectedDate, inSameDayAs: lastDay) { + selectedDate = today } lastDay = today - datePicker.timeZone = stats.calendar.timeZone - datePicker.maxDate = now + updateDateButton(calendar: stats.calendar, now: now) let recent = mode.selectedSegment == 1 - datePicker.isEnabled = !recent + datePickerButton.isEnabled = !recent + backToTodayButton.isHidden = recent || stats.calendar.isDate(selectedDate, inSameDayAs: now) previousButton.isEnabled = !recent - nextButton.isEnabled = !recent && stats.calendar.startOfDay(for: datePicker.dateValue) < today - let points = stats.points(on: datePicker.dateValue, recent24Hours: recent, now: now) + nextButton.isEnabled = !recent && stats.calendar.startOfDay(for: selectedDate) < today + let points = stats.points(on: selectedDate, recent24Hours: recent, now: now) chart.timeZone = stats.calendar.timeZone chart.points = points let recorded = points.compactMap(\.counts) @@ -127,9 +161,21 @@ final class HourlyStatsViewController: NSViewController { showDetail(chart.hoveredPoint) } - private func configure(_ button: NSButton, title: String, label: String, action: Selector) { - button.title = title + private func configure(_ button: NSButton, symbol: String, label: String, action: Selector) { + button.title = "" + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) + button.contentTintColor = .labelColor + button.controlSize = .regular + button.setButtonType(.momentaryPushIn) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.setContentHuggingPriority(.required, for: .horizontal) + button.imagePosition = .imageOnly button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 32) + ]) button.target = self button.action = action button.toolTip = NSLocalizedString(label, comment: "") @@ -152,7 +198,53 @@ final class HourlyStatsViewController: NSViewController { } } + private func updateDateButton(calendar: Calendar, now: Date) { + let sameYear = calendar.component(.year, from: selectedDate) == calendar.component(.year, from: now) + let localization = Bundle.main.preferredLocalizations.first ?? "" + let language = Locale.preferredLanguages.first ?? "" + let title: String + if localization.hasPrefix("zh") || language.hasPrefix("zh") { + let monthDay = "\(calendar.component(.month, from: selectedDate))月\(calendar.component(.day, from: selectedDate))日" + title = sameYear ? monthDay : "\(calendar.component(.year, from: selectedDate))年" + monthDay + } else { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.setLocalizedDateFormatFromTemplate(sameYear ? "Md" : "yMd") + title = formatter.string(from: selectedDate) + } + let attributedTitle = NSAttributedString(string: title, attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 14, weight: .semibold), + .foregroundColor: NSColor.labelColor + ]) + datePickerButton.attributedTitle = attributedTitle + datePickerButton.attributedAlternateTitle = attributedTitle + datePickerButton.setAccessibilityValue(title) + } + + @objc private func toggleDatePicker() { + if datePickerPopover.isShown { + datePickerPopover.performClose(nil) + return + } + let calendar = StatsManager.shared.hourlyStatsSnapshot().calendar + datePickerController.update(selectedDate: selectedDate, + bounds: (.distantPast, Date()), calendar: calendar) + datePickerController.prepareForPresentation() + datePickerPopover.contentSize = datePickerController.preferredContentSize + datePickerPopover.show(relativeTo: datePickerButton.bounds, of: datePickerButton, preferredEdge: .maxY) + AppDelegate.trackClick("hourly_open_calendar") + } + + @objc private func backToToday() { + selectedDate = StatsManager.shared.hourlyStatsSnapshot().calendar.startOfDay(for: Date()) + AppDelegate.trackClick("hourly_back_to_today") + chart.clearHover() + refresh() + } + @objc private func modeChanged() { + datePickerPopover.performClose(nil) AppDelegate.trackClick("hourly_mode", properties: ["mode": mode.selectedSegment == 0 ? "date" : "recent_24_hours"]) chart.clearHover() refresh() @@ -166,8 +258,8 @@ final class HourlyStatsViewController: NSViewController { private func moveDay(_ offset: Int) { let calendar = StatsManager.shared.hourlyStatsSnapshot().calendar - guard let date = calendar.date(byAdding: .day, value: offset, to: datePicker.dateValue) else { return } - datePicker.dateValue = min(date, Date()) + guard let date = calendar.date(byAdding: .day, value: offset, to: selectedDate) else { return } + selectedDate = min(date, Date()) AppDelegate.trackClick("hourly_day", properties: ["direction": offset < 0 ? "previous" : "next"]) chart.clearHover() refresh() diff --git a/KeyStats/KeyboardHeatmapViewController.swift b/KeyStats/KeyboardHeatmapViewController.swift index 67eb4c7..4347573 100644 --- a/KeyStats/KeyboardHeatmapViewController.swift +++ b/KeyStats/KeyboardHeatmapViewController.swift @@ -665,7 +665,7 @@ private final class EmptyStateBadgeView: NSVisualEffectView { } } -private final class HeatmapDatePickerPopoverViewController: NSViewController { +final class HeatmapDatePickerPopoverViewController: NSViewController { private static let contentInset = NSEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) static let contentSize = NSSize(width: 220, height: 200) @@ -697,7 +697,9 @@ private final class HeatmapDatePickerPopoverViewController: NSViewController { configureConstraintsIfNeeded() } - func update(selectedDate: Date, bounds: (start: Date, end: Date)) { + func update(selectedDate: Date, bounds: (start: Date, end: Date), calendar: Calendar = .current) { + datePicker.calendar = calendar + datePicker.timeZone = calendar.timeZone datePicker.minDate = bounds.start datePicker.maxDate = bounds.end datePicker.dateValue = selectedDate @@ -711,7 +713,7 @@ private final class HeatmapDatePickerPopoverViewController: NSViewController { } @objc private func datePicked(_ sender: NSDatePicker) { - onDateSelected?(Calendar.current.startOfDay(for: sender.dateValue)) + onDateSelected?((sender.calendar ?? Calendar.current).startOfDay(for: sender.dateValue)) } private func configureConstraintsIfNeeded() { From 8b325a9b88aeb91ca169b1adc21375d4097d263d Mon Sep 17 00:00:00 2001 From: pipizhu Date: Thu, 10 Sep 2026 18:40:58 +0800 Subject: [PATCH 3/8] feat(macos): back up and sync hourly input trends --- KeyStats/HourlyStats.swift | 141 ++++++++++++++++++++- KeyStats/HourlyStatsChartView.swift | 34 ++--- KeyStats/HourlyStatsViewController.swift | 42 ++++-- KeyStats/StatsManager.swift | 58 ++++++--- KeyStats/StatsModels.swift | 10 ++ KeyStats/Sync/DisplayStatsAggregator.swift | 33 ++++- KeyStats/Sync/SyncCoordinator.swift | 15 ++- KeyStats/Sync/SyncCrypto.swift | 3 +- KeyStats/Sync/SyncModels.swift | 10 +- KeyStats/en.lproj/Localizable.strings | 9 +- KeyStats/zh-Hans.lproj/Localizable.strings | 9 +- KeyStats/zh-Hant.lproj/Localizable.strings | 9 +- KeyStatsTests/HourlyStatsTests.swift | 135 ++++++++++++++++++++ 13 files changed, 442 insertions(+), 66 deletions(-) diff --git a/KeyStats/HourlyStats.swift b/KeyStats/HourlyStats.swift index d08d17a..bab5b07 100644 --- a/KeyStats/HourlyStats.swift +++ b/KeyStats/HourlyStats.swift @@ -1,8 +1,8 @@ import Foundation -/// Local aggregate counters only. The recording time zone stays fixed so travel +/// Aggregate counters only. The recording time zone stays fixed so travel /// cannot move previously recorded counts into a different hour or date. -struct HourlyStats: Codable { +struct HourlyStats: Codable, Equatable { struct Counts: Codable, Equatable { var keys = 0 var clicks = 0 @@ -16,6 +16,7 @@ struct HourlyStats: Codable { let startedAt: Date let timeZoneIdentifier: String private var buckets: [String: Counts] = [:] + private(set) var recordedThrough: Date? init(startedAt: Date = Date(), timeZone: TimeZone = .current) { self.startedAt = startedAt @@ -40,6 +41,7 @@ struct HourlyStats: Codable { /// Includes the current, incomplete hour and the 23 preceding hourly buckets. func points(on date: Date, recent24Hours: Bool, now: Date = Date()) -> [Point] { + let availableThrough = min(now, recordedThrough ?? now) let start: Date let end: Date if recent24Hours { @@ -54,14 +56,147 @@ struct HourlyStats: Codable { var result: [Point] = [] var hour = start while hour < end { - let available = hour <= now && hour.addingTimeInterval(3600) > startedAt + let available = hour <= availableThrough && hour.addingTimeInterval(3600) > startedAt result.append(Point(date: hour, counts: available ? (buckets[bucketKey(hour)] ?? Counts()) : nil)) hour = hour.addingTimeInterval(3600) } return result } + + private enum CodingKeys: String, CodingKey { + case startedAt, timeZoneIdentifier, buckets, recordedThrough + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + startedAt = try values.decode(Date.self, forKey: .startedAt) + timeZoneIdentifier = try values.decode(String.self, forKey: .timeZoneIdentifier) + buckets = try values.decode([String: Counts].self, forKey: .buckets) + recordedThrough = try values.decodeIfPresent(Date.self, forKey: .recordedThrough) + try validate() + } + + func validate(day: String? = nil) throws { + guard TimeZone(identifier: timeZoneIdentifier) != nil, + startedAt.timeIntervalSince1970.isFinite, + recordedThrough.map({ $0.timeIntervalSince1970.isFinite && $0 >= startedAt }) ?? true, + day == nil || buckets.count <= 26 else { throw SyncValidationError.invalidSnapshot } + for (key, counts) in buckets { + guard let timestamp = Int64(key), String(timestamp) == key, + counts.keys >= 0, counts.clicks >= 0 else { throw SyncValidationError.invalidSnapshot } + let date = Date(timeIntervalSince1970: Double(timestamp)) + guard let hour = calendar.dateInterval(of: .hour, for: date), hour.start == date, + hour.end > startedAt, recordedThrough.map({ date <= $0 }) ?? true, + day.map({ dayKey(date) == $0 }) ?? true else { throw SyncValidationError.invalidSnapshot } + } + } + + /// Optional per-day extension to the existing encrypted sync record. + /// A stable end-of-day cutoff avoids re-uploading unchanged archives. + func syncShards(now: Date = Date()) -> [String: HourlyStats] { + let grouped = Dictionary(grouping: buckets.keys) { key in + dayKey(Date(timeIntervalSince1970: Double(Int64(key) ?? 0))) + } + var result: [String: HourlyStats] = [:] + let currentDay = dayKey(now) + for day in Set(grouped.keys).union([currentDay]) { + var shard = self + shard.buckets = Dictionary(uniqueKeysWithValues: (grouped[day] ?? []).compactMap { key in + buckets[key].map { (key, $0) } + }) + let sample = shard.buckets.keys.first.flatMap(Int64.init).map { Date(timeIntervalSince1970: Double($0)) } ?? now + let end = calendar.dateInterval(of: .day, for: sample)?.end ?? now + shard.recordedThrough = max(startedAt, min(now, end.addingTimeInterval(-0.001))) + result[day] = shard + } + return result + } + + /// Merge imports using the same additive policy as daily statistics. + /// Different hour boundaries cannot be reconstructed from aggregate counts. + func mergingImport(_ other: HourlyStats) throws -> HourlyStats { + guard timeZoneIdentifier == other.timeZoneIdentifier else { throw HourlyStatsImportError.differentTimeZones } + var merged = HourlyStats(startedAt: min(startedAt, other.startedAt), timeZone: calendar.timeZone) + merged.buckets = buckets + for (key, value) in other.buckets { + let old = merged.buckets[key] ?? Counts() + merged.buckets[key] = Counts(keys: saturatingNonnegativeSum([old.keys, value.keys]), + clicks: saturatingNonnegativeSum([old.clicks, value.clicks])) + } + return merged + } + + func forLocalRecording() -> HourlyStats { + var copy = self + copy.recordedThrough = nil + return copy + } + + /// Call only after selecting the latest revision of each device/day shard. + static func combineShards(_ shards: [HourlyStats]) -> HourlyStats? { + guard let first = shards.first, + shards.allSatisfy({ $0.timeZoneIdentifier == first.timeZoneIdentifier }) else { return nil } + var result = HourlyStats(startedAt: shards.map(\.startedAt).min() ?? first.startedAt, timeZone: first.calendar.timeZone) + for shard in shards { + for (key, counts) in shard.buckets { + result.buckets[key] = counts + } + } + result.recordedThrough = shards.compactMap(\.recordedThrough).max() + return result + } + + /// Like daily sync, compare each device's local date/hour, not a rebinned + /// absolute timeline (an aggregate cannot be split across time-zone offsets). + func countsAligned(to target: [Point], calendar displayCalendar: Calendar, now: Date) -> [Counts?] { + var result = Array(repeating: nil, count: target.count) + let dayGroups = Dictionary(grouping: target.indices) { index in + let parts = displayCalendar.dateComponents([.year, .month, .day], from: target[index].date) + return parts + } + for (day, indices) in dayGroups { + var noon = day + noon.hour = 12 + guard let sourceDate = calendar.date(from: noon) else { continue } + let source = points(on: sourceDate, recent24Hours: false, now: now) + guard let firstIndex = indices.first, + let targetDay = displayCalendar.dateInterval(of: .day, for: target[firstIndex].date) else { continue } + let fullDay = stride(from: targetDay.start.timeIntervalSince1970, + to: targetDay.end.timeIntervalSince1970, by: 3600).map { Date(timeIntervalSince1970: $0) } + let targetHours = Dictionary(grouping: fullDay) { displayCalendar.component(.hour, from: $0) } + let visibleIndices = Dictionary(uniqueKeysWithValues: indices.map { (target[$0].date, $0) }) + let sourceHours = Dictionary(grouping: source) { calendar.component(.hour, from: $0.date) } + for (hour, sourcePoints) in sourceHours { + guard let slots = targetHours[hour] else { continue } + for (occurrence, point) in sourcePoints.enumerated() { + guard let counts = point.counts else { continue } + // Preserve repeated hours when present on both devices; otherwise + // combine the repeated source hour into the available target hour. + guard let index = visibleIndices[slots[min(occurrence, slots.count - 1)]] else { continue } + let previous = result[index] ?? Counts() + result[index] = Counts(keys: saturatingNonnegativeSum([previous.keys, counts.keys]), + clicks: saturatingNonnegativeSum([previous.clicks, counts.clicks])) + } + } + } + return result + } + + private func dayKey(_ date: Date) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + } + private func bucketKey(_ date: Date) -> String { String(Int64(date.timeIntervalSince1970)) } } + +enum HourlyStatsImportError: LocalizedError { + case differentTimeZones + + var errorDescription: String? { + NSLocalizedString("hourly.import.differentTimeZones", comment: "") + } +} diff --git a/KeyStats/HourlyStatsChartView.swift b/KeyStats/HourlyStatsChartView.swift index b12ca19..fe07b5e 100644 --- a/KeyStats/HourlyStatsChartView.swift +++ b/KeyStats/HourlyStatsChartView.swift @@ -5,13 +5,15 @@ final class HourlyStatsChartView: NSView { var points: [HourlyStats.Point] = [] { didSet { needsDisplay = true - setAccessibilityValue(points.map { point in - let counts = point.counts.map { "\($0.keys) / \($0.clicks)" } - ?? NSLocalizedString("hourly.unavailable", comment: "") - return "\(hourLabel(point.date)): \(counts)" + setAccessibilityValue(points.enumerated().map { index, point in + let total = point.counts.map { String(value($0)) } ?? "—" + let local = localPoints.indices.contains(index) ? localPoints[index].counts.map { String(value($0)) } ?? "—" : "—" + return "\(hourLabel(point.date)): \(total) / \(local)" }.joined(separator: "; ")) } } + var localPoints: [HourlyStats.Point] = [] + var showsClicks = false var timeZone = TimeZone.current var onHover: ((HourlyStats.Point?) -> Void)? private var hoverIndex: Int? @@ -77,25 +79,25 @@ final class HourlyStatsChartView: NSView { NSColor.controlBackgroundColor.withAlphaComponent(0.5).setFill() NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10).fill() let rect = plotRect - let maximum = max(4, points.reduce(0) { max($0, max($1.counts?.keys ?? 0, $1.counts?.clicks ?? 0)) }) + let maximum = max(4, points.reduce(0) { max($0, $1.counts.map(value) ?? 0) }) for step in 0...4 { let y = rect.minY + rect.height * CGFloat(step) / 4 stroke(from: NSPoint(x: rect.minX, y: y), to: NSPoint(x: rect.maxX, y: y), color: .separatorColor, width: 0.5) label(String(format: "%.0f", Double(maximum) * Double(step) / 4), at: NSPoint(x: 4, y: y - 6), color: .secondaryLabelColor) } - let keyLabel = NSLocalizedString("history.metric.keys", comment: "") - let clickLabel = NSLocalizedString("history.metric.clicks", comment: "") + let keyLabel = NSLocalizedString("history.series.synced", comment: "") + let clickLabel = NSLocalizedString("history.series.local", comment: "") label("● " + keyLabel, at: NSPoint(x: rect.minX, y: bounds.height - 22), color: .systemBlue) - label("◆ " + clickLabel, at: NSPoint(x: rect.minX + 110, y: bounds.height - 22), color: .systemOrange) + label("◆ " + clickLabel, at: NSPoint(x: rect.minX + 160, y: bounds.height - 22), color: .systemOrange) guard !points.isEmpty else { return } for index in points.indices where index % 3 == 0 || index == points.count - 1 { // Avoid colliding with the last tick on 23/25-hour daylight-saving days. if index != points.count - 1 && points.count - 1 - index < 2 { continue } label(hourLabel(points[index].date), at: NSPoint(x: x(index) - 16, y: 10), color: .secondaryLabelColor) } - drawSeries(color: .systemBlue, maximum: maximum, clicks: false) - drawSeries(color: .systemOrange, maximum: maximum, clicks: true) + drawSeries(points, color: .systemBlue, maximum: maximum, dashed: false) + drawSeries(localPoints, color: .systemOrange, maximum: maximum, dashed: true) if points.allSatisfy({ $0.counts == nil }) { label(NSLocalizedString("hourly.empty", comment: ""), at: NSPoint(x: rect.minX + 16, y: rect.midY), color: .secondaryLabelColor) } @@ -108,16 +110,18 @@ final class HourlyStatsChartView: NSView { plotRect.minX + plotRect.width * CGFloat(index) / CGFloat(max(1, points.count - 1)) } - private func drawSeries(color: NSColor, maximum: Int, clicks: Bool) { + func value(_ counts: HourlyStats.Counts) -> Int { showsClicks ? counts.clicks : counts.keys } + + private func drawSeries(_ series: [HourlyStats.Point], color: NSColor, maximum: Int, dashed: Bool) { let path = NSBezierPath() var previous: NSPoint? - for (index, point) in points.enumerated() { + for (index, point) in series.enumerated() { guard let counts = point.counts else { previous = nil; continue } - let value = clicks ? counts.clicks : counts.keys + let value = value(counts) let position = NSPoint(x: x(index), y: plotRect.minY + plotRect.height * CGFloat(value) / CGFloat(maximum)) if previous != nil { path.line(to: position) } else { path.move(to: position) } color.setFill() - if clicks { + if dashed { let diamond = NSBezierPath() diamond.move(to: NSPoint(x: position.x, y: position.y + 3)) diamond.line(to: NSPoint(x: position.x + 3, y: position.y)) @@ -132,7 +136,7 @@ final class HourlyStatsChartView: NSView { } color.setStroke() path.lineWidth = 2 - if clicks { path.setLineDash([5, 3], count: 2, phase: 0) } + if dashed { path.setLineDash([5, 3], count: 2, phase: 0) } path.stroke() } diff --git a/KeyStats/HourlyStatsViewController.swift b/KeyStats/HourlyStatsViewController.swift index 20d4ffc..5804a49 100644 --- a/KeyStats/HourlyStatsViewController.swift +++ b/KeyStats/HourlyStatsViewController.swift @@ -23,6 +23,7 @@ final class HourlyStatsViewController: NSViewController { private let previousButton = NSButton() private let nextButton = NSButton() private let mode = NSSegmentedControl() + private let metric = NSSegmentedControl() private let summary = NSTextField(labelWithString: "") private let detail = NSTextField(labelWithString: "") private let note = NSTextField(wrappingLabelWithString: "") @@ -38,6 +39,12 @@ final class HourlyStatsViewController: NSViewController { super.viewDidLoad() let title = NSTextField(labelWithString: NSLocalizedString("hourly.title", comment: "")) title.font = .systemFont(ofSize: 22, weight: .semibold) + metric.segmentCount = 2 + metric.setLabel(NSLocalizedString("history.metric.keys", comment: ""), forSegment: 0) + metric.setLabel(NSLocalizedString("history.metric.clicks", comment: ""), forSegment: 1) + metric.selectedSegment = 0 + metric.target = self + metric.action = #selector(metricChanged) mode.segmentCount = 2 mode.setLabel(NSLocalizedString("hourly.byDate", comment: ""), forSegment: 0) mode.setLabel(NSLocalizedString("hourly.recent", comment: ""), forSegment: 1) @@ -74,11 +81,14 @@ final class HourlyStatsViewController: NSViewController { detail.textColor = .secondaryLabelColor note.font = .systemFont(ofSize: 11) note.textColor = .secondaryLabelColor - for child in [title, controls, summary, chart, detail, note] { + for child in [title, metric, controls, summary, chart, detail, note] { child.translatesAutoresizingMaskIntoConstraints = false view.addSubview(child) } NSLayoutConstraint.activate([ + metric.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + metric.centerYAnchor.constraint(equalTo: title.centerYAnchor), + metric.leadingAnchor.constraint(greaterThanOrEqualTo: title.trailingAnchor, constant: 16), datePickerButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 78), title.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), title.topAnchor.constraint(equalTo: view.topAnchor, constant: 24), @@ -136,21 +146,24 @@ final class HourlyStatsViewController: NSViewController { backToTodayButton.isHidden = recent || stats.calendar.isDate(selectedDate, inSameDayAs: now) previousButton.isEnabled = !recent nextButton.isEnabled = !recent && stats.calendar.startOfDay(for: selectedDate) < today - let points = stats.points(on: selectedDate, recent24Hours: recent, now: now) + let series = StatsManager.shared.hourlyTrendSeries(date: selectedDate, recent24Hours: recent) + let points = series.total + chart.showsClicks = metric.selectedSegment == 1 + chart.localPoints = series.local chart.timeZone = stats.calendar.timeZone chart.points = points let recorded = points.compactMap(\.counts) if recorded.isEmpty { summary.stringValue = NSLocalizedString("hourly.empty", comment: "") } else { - let keys = saturatingNonnegativeSum(recorded.map(\.keys)) - let clicks = saturatingNonnegativeSum(recorded.map(\.clicks)) + let total = saturatingNonnegativeSum(recorded.map(chart.value)) + let local = saturatingNonnegativeSum(series.local.compactMap(\.counts).map(chart.value)) let peak = points.filter { $0.counts != nil }.max { - saturatingNonnegativeSum([$0.counts?.keys ?? 0, $0.counts?.clicks ?? 0]) < - saturatingNonnegativeSum([$1.counts?.keys ?? 0, $1.counts?.clicks ?? 0]) + $0.counts.map(chart.value) ?? 0 < $1.counts.map(chart.value) ?? 0 } - let peakText = (keys > 0 || clicks > 0) ? peak.map { chart.hourLabel($0.date) } ?? "—" : "—" - summary.stringValue = String(format: NSLocalizedString("hourly.summary", comment: ""), keys, clicks, peakText) + let peakText = total > 0 ? peak.map { chart.hourLabel($0.date) } ?? "—" : "—" + summary.stringValue = String(format: NSLocalizedString("hourly.summary.metric", comment: ""), + total, local, peakText) } let formatter = DateFormatter() formatter.timeZone = stats.calendar.timeZone @@ -191,11 +204,9 @@ final class HourlyStatsViewController: NSViewController { formatter.timeZone = chart.timeZone formatter.setLocalizedDateFormatFromTemplate("MMMd HH:mm z") let time = formatter.string(from: point.date) - if let counts = point.counts { - detail.stringValue = String(format: NSLocalizedString("hourly.detail", comment: ""), time, counts.keys, counts.clicks) - } else { - detail.stringValue = time + " · " + NSLocalizedString("hourly.unavailable", comment: "") - } + let total = point.counts.map { String(chart.value($0)) } ?? "—" + let local = chart.localPoints.first(where: { $0.date == point.date })?.counts.map { String(chart.value($0)) } ?? "—" + detail.stringValue = String(format: NSLocalizedString("hourly.detail.metric", comment: ""), time, total, local) } private func updateDateButton(calendar: Calendar, now: Date) { @@ -243,6 +254,11 @@ final class HourlyStatsViewController: NSViewController { refresh() } + @objc private func metricChanged() { + AppDelegate.trackClick("hourly_metric", properties: ["metric": metric.selectedSegment == 0 ? "keys" : "clicks"]) + refresh() + } + @objc private func modeChanged() { datePickerPopover.performClose(nil) AppDelegate.trackClick("hourly_mode", properties: ["mode": mode.selectedSegment == 0 ? "date" : "recent_24_hours"]) diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index d02f6de..d1f553c 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -876,6 +876,16 @@ class StatsManager { return hourlyStats } + func hourlyTrendSeries(date: Date, recent24Hours: Bool) -> (total: [HourlyStats.Point], local: [HourlyStats.Point]) { + let local = hourlyStatsSnapshot() + let state = SyncCoordinator.shared.state + let remote = state.isConfigured && !state.needsRepair + ? RemoteShardCache.shared.snapshots(excludingDeviceId: state.deviceId) : [] + return DisplayStatsAggregator.hourlySeries(local: local, remote: remote, + currentDeviceId: state.deviceId, + date: date, recent24Hours: recent24Hours) + } + private func loadStats() -> DailyStats? { guard let data = userDefaults.data(forKey: statsKey), let stats = try? JSONDecoder().decode(DailyStats.self, from: data) else { @@ -894,13 +904,6 @@ class StatsManager { // MARK: - 数据导入导出 - private struct ExportPayload: Codable { - let version: Int - let scope: String? - let exportedAt: Date - let currentStats: DailyStats - let history: [String: DailyStats] - } private enum ImportError: LocalizedError { case invalidFormat @@ -926,18 +929,20 @@ class StatsManager { statsStateLock.lock() var exportHistory = history var current = currentStats + let hourly = hourlyStats statsStateLock.unlock() let normalizedDate = Calendar.current.startOfDay(for: current.date) current.date = normalizedDate let key = dateFormatter.string(from: normalizedDate) exportHistory[key] = current - let payload = ExportPayload( + let payload = StatsExportPayload( version: 1, scope: "currentDevice", exportedAt: Date(), currentStats: current, - history: exportHistory + history: exportHistory, + hourlyStats: hourly ) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] @@ -952,22 +957,19 @@ class StatsManager { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let payload: ExportPayload + let payload: StatsExportPayload do { - payload = try decoder.decode(ExportPayload.self, from: data) + payload = try decoder.decode(StatsExportPayload.self, from: data) } catch { throw ImportError.invalidFormat } guard payload.version == 1 else { throw ImportError.unsupportedVersion } - applyImportedPayload(payload, mode: mode) + try applyImportedPayload(payload, mode: mode) } - private func applyImportedPayload(_ payload: ExportPayload, mode: ImportMode) { - saveTimer?.invalidate() - saveTimer = nil - + private func applyImportedPayload(_ payload: StatsExportPayload, mode: ImportMode) throws { var importedHistory = normalizedHistory(payload.history) let importedCurrent = normalizedDailyStats(payload.currentStats) importedHistory[dateFormatter.string(from: importedCurrent.date)] = importedCurrent @@ -983,7 +985,23 @@ class StatsManager { } statsStateLock.lock() + let resolvedHourly: HourlyStats + do { + if let imported = payload.hourlyStats { + switch mode { + case .overwrite: resolvedHourly = imported.forLocalRecording() + case .merge: resolvedHourly = try hourlyStats.mergingImport(imported) + } + } else { + // Legacy files must not erase the hourly recording. + resolvedHourly = hourlyStats + } + } catch { + statsStateLock.unlock() + throw error + } history = resolvedHistory + hourlyStats = resolvedHourly currentStats = resolvedHistory[todayKey] ?? DailyStats(date: today) recentKeyTimestamps.removeAll() recentClickTimestamps.removeAll() @@ -1637,13 +1655,19 @@ extension StatsManager { /// Returns the current device's writable history only. Remote shards are never included. func localSyncHistorySnapshot() -> [String: DailyStats] { + localSyncSnapshot().history + } + + /// Daily and hourly counters must describe the same input-event boundary. + func localSyncSnapshot() -> (history: [String: DailyStats], hourly: HourlyStats) { statsStateLock.lock() var snapshot = history let current = currentStats + let hourly = hourlyStats statsStateLock.unlock() let normalized = normalizedDailyStats(current) snapshot[dateFormatter.string(from: normalized.date)] = normalized - return normalizedHistory(snapshot) + return (normalizedHistory(snapshot), hourly) } func displayCurrentStats() -> DailyStats { diff --git a/KeyStats/StatsModels.swift b/KeyStats/StatsModels.swift index dbbc7dc..3adc5d4 100644 --- a/KeyStats/StatsModels.swift +++ b/KeyStats/StatsModels.swift @@ -687,3 +687,13 @@ struct AllTimeStats { ) } } + +/// Current-device backup. Missing hourlyStats is a valid legacy v1 file. +struct StatsExportPayload: Codable { + let version: Int + let scope: String? + let exportedAt: Date + let currentStats: DailyStats + let history: [String: DailyStats] + let hourlyStats: HourlyStats? +} diff --git a/KeyStats/Sync/DisplayStatsAggregator.swift b/KeyStats/Sync/DisplayStatsAggregator.swift index a6551be..de41b75 100644 --- a/KeyStats/Sync/DisplayStatsAggregator.swift +++ b/KeyStats/Sync/DisplayStatsAggregator.swift @@ -4,7 +4,8 @@ enum DisplayStatsAggregator { static func coreSnapshot( from stats: DailyStats, deviceId: String, - revision: Int64 + revision: Int64, + hourlyStats: HourlyStats? = nil ) throws -> CoreDaySnapshotV1 { var counts: [String: Int64] = [:] for (rawKey, rawCount) in stats.keyPressCounts { @@ -25,7 +26,8 @@ enum DisplayStatsAggregator { middle: Int64(max(0, stats.middleClicks)), sideBack: Int64(max(0, stats.sideBackClicks)), sideForward: Int64(max(0, stats.sideForwardClicks)) - ) + ), + hourlyStats: hourlyStats ).validated() } @@ -81,6 +83,33 @@ enum DisplayStatsAggregator { return Array(latest.values) } + static func hourlySeries(local: HourlyStats, remote: [CoreDaySnapshotV1], currentDeviceId: String, + date: Date, recent24Hours: Bool, now: Date = Date()) -> (total: [HourlyStats.Point], local: [HourlyStats.Point]) { + let localPoints = local.points(on: date, recent24Hours: recent24Hours, now: now) + var totals = localPoints.map(\.counts) + let visibleDays = Set(localPoints.map { point in + let parts = local.calendar.dateComponents([.year, .month, .day], from: point.date) + return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + }) + let latest = deduplicatedLatest(remote.filter { + $0.deviceId != currentDeviceId && visibleDays.contains($0.localDay) + }) + let groups = Dictionary(grouping: latest.filter { $0.hourlyStats != nil }) { + "\($0.deviceId)|\($0.hourlyStats?.timeZoneIdentifier ?? "")" + } + for shards in groups.values { + guard let hourly = HourlyStats.combineShards(shards.compactMap(\.hourlyStats)) else { continue } + let aligned = hourly.countsAligned(to: localPoints, calendar: local.calendar, now: now) + for index in totals.indices { + guard let counts = aligned[index] else { continue } + let old = totals[index] ?? HourlyStats.Counts() + totals[index] = HourlyStats.Counts(keys: saturatingNonnegativeSum([old.keys, counts.keys]), + clicks: saturatingNonnegativeSum([old.clicks, counts.clicks])) + } + } + return (zip(localPoints, totals).map { HourlyStats.Point(date: $0.0.date, counts: $0.1) }, localPoints) + } + private static func add(_ local: Int, _ remote: Int64) -> Int { guard remote > 0 else { return max(0, local) } if remote >= Int64(Int.max) { return Int.max } diff --git a/KeyStats/Sync/SyncCoordinator.swift b/KeyStats/Sync/SyncCoordinator.swift index 2443b99..2e19579 100644 --- a/KeyStats/Sync/SyncCoordinator.swift +++ b/KeyStats/Sync/SyncCoordinator.swift @@ -617,7 +617,8 @@ final class SyncCoordinator { revision: 0, keyPresses: snapshot.keyPresses, keyPressCounts: snapshot.keyPressCounts, - clicks: snapshot.clicks + clicks: snapshot.clicks, + hourlyStats: snapshot.hourlyStats ).validated() let contentHash = try SyncCrypto.contentHash(content) let previousRevision = state.revisions[snapshot.localDay]?.revision ?? 0 @@ -1112,13 +1113,18 @@ final class SyncCoordinator { private func prepareRecords() throws -> (current: EncryptedSyncRecordV1?, archives: [EncryptedSyncRecordV1]) { guard let vaultId = state.vaultId else { throw SyncCoordinatorError.notConfigured } let seed = try boundCredentials().recoverySeed - let localHistory = StatsManager.shared.localSyncHistorySnapshot() + let localSnapshot = StatsManager.shared.localSyncSnapshot() + var localHistory = localSnapshot.history + let hourlyShards = localSnapshot.hourly.syncShards() + for day in hourlyShards.keys where localHistory[day] == nil { + if let date = SyncDay.date(from: day) { localHistory[day] = DailyStats(date: date) } + } let today = SyncDay.string(from: Date()) var current: EncryptedSyncRecordV1? var archives: [EncryptedSyncRecordV1] = [] for (day, daily) in localHistory.sorted(by: { $0.key < $1.key }) { - let provisional = try DisplayStatsAggregator.coreSnapshot(from: daily, deviceId: state.deviceId, revision: 0) + let provisional = try DisplayStatsAggregator.coreSnapshot(from: daily, deviceId: state.deviceId, revision: 0, hourlyStats: hourlyShards[day]) let contentHash = try SyncCrypto.contentHash(provisional) let previous = state.revisions[day] let existingEnvelope = state.pendingRecords[day] @@ -1134,7 +1140,8 @@ final class SyncCoordinator { let snapshot = try DisplayStatsAggregator.coreSnapshot( from: daily, deviceId: state.deviceId, - revision: revision + revision: revision, + hourlyStats: hourlyShards[day] ) record = try SyncCrypto.encrypt(snapshot: snapshot, vaultId: vaultId, seed: seed) state.revisions[day] = SyncRevisionState(revision: revision, contentHash: contentHash) diff --git a/KeyStats/Sync/SyncCrypto.swift b/KeyStats/Sync/SyncCrypto.swift index ef92e4b..6544a3b 100644 --- a/KeyStats/Sync/SyncCrypto.swift +++ b/KeyStats/Sync/SyncCrypto.swift @@ -209,7 +209,8 @@ enum SyncCrypto { revision: 0, keyPresses: content.keyPresses, keyPressCounts: content.keyPressCounts, - clicks: content.clicks + clicks: content.clicks, + hourlyStats: content.hourlyStats ) return Data(SHA256.hash(data: try SyncJSON.encoder.encode(content))).base64URLEncodedString() } diff --git a/KeyStats/Sync/SyncModels.swift b/KeyStats/Sync/SyncModels.swift index 4894759..05731c4 100644 --- a/KeyStats/Sync/SyncModels.swift +++ b/KeyStats/Sync/SyncModels.swift @@ -49,6 +49,7 @@ struct CoreDaySnapshotV1: Codable, Equatable { let keyPresses: Int64 let keyPressCounts: [String: Int64] let clicks: CoreClickSnapshotV1 + let hourlyStats: HourlyStats? init( schemaVersion: Int = SyncConstants.schemaVersion, @@ -57,7 +58,8 @@ struct CoreDaySnapshotV1: Codable, Equatable { revision: Int64, keyPresses: Int64, keyPressCounts: [String: Int64], - clicks: CoreClickSnapshotV1 + clicks: CoreClickSnapshotV1, + hourlyStats: HourlyStats? = nil ) { self.schemaVersion = schemaVersion self.deviceId = deviceId @@ -66,6 +68,7 @@ struct CoreDaySnapshotV1: Codable, Equatable { self.keyPresses = keyPresses self.keyPressCounts = keyPressCounts self.clicks = clicks + self.hourlyStats = hourlyStats } func validated() throws -> CoreDaySnapshotV1 { @@ -81,6 +84,8 @@ struct CoreDaySnapshotV1: Codable, Equatable { throw SyncValidationError.invalidSnapshot } + try hourlyStats?.validate(day: localDay) + var normalizedCounts: [String: Int64] = [:] for (rawKey, value) in keyPressCounts { guard value >= 0 else { throw SyncValidationError.invalidSnapshot } @@ -97,7 +102,8 @@ struct CoreDaySnapshotV1: Codable, Equatable { revision: revision, keyPresses: keyPresses, keyPressCounts: normalizedCounts, - clicks: clicks + clicks: clicks, + hourlyStats: hourlyStats ) let encoded = try SyncJSON.encoder.encode(normalized) guard encoded.count <= SyncConstants.maximumSnapshotBytes else { throw SyncValidationError.snapshotTooLarge } diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index 611d2b7..1a55f7e 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -366,7 +366,10 @@ "hourly.empty" = "No hourly data for this date"; "hourly.unavailable" = "No hourly data"; "hourly.summary" = "Recorded totals: %ld keys · %ld clicks · Busiest hour: %@"; -"hourly.note" = "Local data · Recording since %@ · %@\nThe first and current hours may be incomplete. Last 24 Hours includes the current hour and the preceding 23 hours."; -"hourly.hover" = "Hover over the chart to see hourly counts."; +"hourly.note" = "This Mac records since %@ · %@\nTotals compare each device’s local date and hour, using its latest synced hourly data. First/current hours may be partial; Last 24 Hours includes the current hour."; +"hourly.hover" = "Hover to compare this Mac with the synced total for the selected metric."; "hourly.detail" = "%@ · %ld keys · %ld clicks"; -"hourly.chartAccessibility" = "Hourly counts: keys (solid blue), clicks (dashed orange). Each hour lists keys / clicks."; +"hourly.chartAccessibility" = "Hourly counts for the selected metric: synced total (solid blue), this Mac (dashed orange). Each hour lists total / local."; +"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."; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index d7e88c1..4ec3908 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -366,7 +366,10 @@ "hourly.empty" = "该日期暂无小时数据"; "hourly.unavailable" = "暂无小时数据"; "hourly.summary" = "已记录合计:按键 %ld · 点击 %ld · 最活跃时段:%@"; -"hourly.note" = "本机数据 · 从 %@ 开始记录 · %@\n起始小时和当前小时可能不完整。「最近 24 小时」包含当前小时及之前的 23 个小时。"; -"hourly.hover" = "将鼠标移至折线图,查看每小时的按键和点击次数。"; +"hourly.note" = "本机从 %@ 开始记录 · %@\n总数按各设备当地日期和小时汇总,以最近同步的小时数据为准。起始和当前小时可能不完整;最近 24 小时包含当前小时。"; +"hourly.hover" = "将鼠标移至折线图,比较当前指标的本设备次数和总数。"; "hourly.detail" = "%@ · 按键 %ld · 点击 %ld"; -"hourly.chartAccessibility" = "每小时次数:按键为蓝色实线,点击为橙色虚线。每个小时依次列出按键数和点击数。"; +"hourly.chartAccessibility" = "所选指标每小时次数:总数为蓝色实线,本设备为橙色虚线。每个小时依次列出总数和本设备次数。"; +"hourly.summary.metric" = "总数:%ld · 本设备:%ld · 最活跃时段:%@"; +"hourly.detail.metric" = "%@ · 总数:%@ · 本设备:%@"; +"hourly.import.differentTimeZones" = "小时记录的时区不同,无法准确合并。请使用覆盖导入恢复备份。"; diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings index 0094364..fdedbc7 100644 --- a/KeyStats/zh-Hant.lproj/Localizable.strings +++ b/KeyStats/zh-Hant.lproj/Localizable.strings @@ -366,7 +366,10 @@ "hourly.empty" = "該日期暫無小時資料"; "hourly.unavailable" = "暫無小時資料"; "hourly.summary" = "已記錄合計:按鍵 %ld · 點擊 %ld · 最活躍時段:%@"; -"hourly.note" = "本機資料 · 從 %@ 開始記錄 · %@\n起始小時和目前小時可能不完整。「最近 24 小時」包含目前小時及之前的 23 個小時。"; -"hourly.hover" = "將滑鼠移至折線圖,查看每小時的按鍵和點擊次數。"; +"hourly.note" = "本機從 %@ 開始記錄 · %@\n總數按各裝置當地日期和小時彙總,以最近同步的小時資料為準。起始和目前小時可能不完整;最近 24 小時包含目前小時。"; +"hourly.hover" = "將滑鼠移至折線圖,比較目前指標的本裝置次數和總數。"; "hourly.detail" = "%@ · 按鍵 %ld · 點擊 %ld"; -"hourly.chartAccessibility" = "每小時次數:按鍵為藍色實線,點擊為橙色虛線。每個小時依次列出按鍵數和點擊數。"; +"hourly.chartAccessibility" = "所選指標每小時次數:總數為藍色實線,本裝置為橙色虛線。每個小時依次列出總數和本裝置次數。"; +"hourly.summary.metric" = "總數:%ld · 本裝置:%ld · 最活躍時段:%@"; +"hourly.detail.metric" = "%@ · 總數:%@ · 本裝置:%@"; +"hourly.import.differentTimeZones" = "小時記錄的時區不同,無法準確合併。請使用覆蓋匯入還原備份。"; diff --git a/KeyStatsTests/HourlyStatsTests.swift b/KeyStatsTests/HourlyStatsTests.swift index 1ef5aac..ef4b825 100644 --- a/KeyStatsTests/HourlyStatsTests.swift +++ b/KeyStatsTests/HourlyStatsTests.swift @@ -87,3 +87,138 @@ final class HourlyStatsTests: XCTestCase { XCTAssertEqual(points[0].counts?.clicks, 0) } } + +extension HourlyStatsTests { + private func snapshot(_ hourly: HourlyStats?, day: String = "2026-09-10", device: String = "remote", revision: Int64 = 1) -> CoreDaySnapshotV1 { + CoreDaySnapshotV1(deviceId: device, localDay: day, revision: revision, + keyPresses: 10, keyPressCounts: [:], clicks: .zero, hourlyStats: hourly) + } + + func testBackupRoundTripAndLegacyBackupWithoutHourlyField() throws { + let start = date("2026-09-10T10:00:00Z") + var hourly = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + hourly.record(keys: 5, clicks: 3, at: start) + let payload = StatsExportPayload(version: 1, scope: "currentDevice", exportedAt: start, + currentStats: DailyStats(date: start), history: [:], hourlyStats: hourly) + let decoded = try SyncJSON.decoder.decode(StatsExportPayload.self, from: SyncJSON.encoder.encode(payload)) + XCTAssertEqual(decoded.hourlyStats, hourly) + let legacy = StatsExportPayload(version: 1, scope: nil, exportedAt: start, + currentStats: DailyStats(date: start), history: [:], hourlyStats: nil) + let data = try SyncJSON.encoder.encode(legacy) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains("hourlyStats")) + XCTAssertNil(try SyncJSON.decoder.decode(StatsExportPayload.self, from: data).hourlyStats) + } + + func testHourlyImportMergeAndTimeZoneMismatch() throws { + let start = date("2026-09-10T10:00:00Z") + var local = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + var imported = local + local.record(keys: 2, at: start) + imported.record(keys: 3, clicks: 4, at: start) + let result = try local.mergingImport(imported) + let points = result.points(on: start, recent24Hours: false, now: start) + XCTAssertEqual(points[10].counts, HourlyStats.Counts(keys: 5, clicks: 4)) + let differentZone = HourlyStats(startedAt: start, timeZone: TimeZone(identifier: "Asia/Shanghai")!) + XCTAssertThrowsError(try local.mergingImport(differentZone)) + XCTAssertEqual(local.points(on: start, recent24Hours: false, now: start)[10].counts?.keys, 2) + } + + func testHourlyEncryptedRoundTripAndHashTracksDistributionNotJustDailyTotal() throws { + let start = date("2026-09-10T10:00:00Z") + let now = date("2026-09-10T12:00:00Z") + var a = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + var b = a + a.record(keys: 10, at: start) + b.record(keys: 10, at: start.addingTimeInterval(3600)) + let first = snapshot(a.syncShards(now: now)["2026-09-10"]) + let second = snapshot(b.syncShards(now: now)["2026-09-10"]) + XCTAssertNotEqual(try SyncCrypto.contentHash(first), try SyncCrypto.contentHash(second)) + let record = try SyncCrypto.encrypt(snapshot: first, vaultId: "vault", seed: Data(0..<16)) + let restored = try SyncCrypto.decrypt(record: record, vaultId: "vault", seed: Data(0..<16)) + XCTAssertEqual(restored, first) + XCTAssertThrowsError(try snapshot(first.hourlyStats, day: "2026-09-09").validated()) + XCTAssertNil(try SyncJSON.decoder.decode(CoreDaySnapshotV1.self, + from: SyncJSON.encoder.encode(snapshot(nil))).hourlyStats) + } + + func testHourlyArchivesHaveStableHashAndStaleRemoteHoursStayUnknown() throws { + let start = date("2026-09-09T10:00:00Z") + var hourly = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + hourly.record(keys: 3, at: start) + let a = hourly.syncShards(now: date("2026-09-10T12:00:00Z")) + let b = hourly.syncShards(now: date("2026-09-11T12:00:00Z")) + XCTAssertEqual(try SyncCrypto.contentHash(snapshot(a["2026-09-09"], day: "2026-09-09")), + try SyncCrypto.contentHash(snapshot(b["2026-09-09"], day: "2026-09-09"))) + let combined = try XCTUnwrap(HourlyStats.combineShards(Array(a.values))) + let points = combined.points(on: date("2026-09-10T12:00:00Z"), recent24Hours: false, + now: date("2026-09-11T12:00:00Z")) + XCTAssertNotNil(points[12].counts) + XCTAssertNil(points[13].counts) + } + + func testSyncedHourlyCacheRestartAndDuplicateRevisionsDoNotAccumulate() throws { + let start = date("2026-09-10T10:00:00Z") + var local = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + local.record(keys: 2, clicks: 1, at: start) + var remote = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + remote.record(keys: 3, clicks: 4, at: start) + let old = snapshot(remote.syncShards(now: start)["2026-09-10"]) + remote.record(keys: 2, at: start) + let latest = snapshot(remote.syncShards(now: start)["2026-09-10"], revision: 2) + let path = FileManager.default.temporaryDirectory.appendingPathComponent("hourly-cache-\(UUID().uuidString).json") + let cache = RemoteShardCache(fileURL: path) + XCTAssertEqual(try cache.apply(recordId: "record", snapshot: old, currentDeviceId: "local"), .inserted) + XCTAssertEqual(try cache.apply(recordId: "record", snapshot: latest, currentDeviceId: "local"), .replaced) + XCTAssertEqual(try cache.apply(recordId: "record", snapshot: latest, currentDeviceId: "local"), .unchanged) + let restored = RemoteShardCache(fileURL: path) + let series = DisplayStatsAggregator.hourlySeries(local: local, + remote: restored.snapshots() + [old, latest, snapshot(latest.hourlyStats, device: "local")], + currentDeviceId: "local", date: start, recent24Hours: false, now: start) + XCTAssertEqual(series.local[10].counts, HourlyStats.Counts(keys: 2, clicks: 1)) + XCTAssertEqual(series.total[10].counts, HourlyStats.Counts(keys: 7, clicks: 5)) + XCTAssertNil(series.total[11].counts) + } + + func testHourlyTotalsAlignLocalClockHoursLikeDailyTrend() throws { + let now = date("2026-09-10T12:00:00Z") + var local = HourlyStats(startedAt: date("2026-09-10T00:00:00Z"), timeZone: TimeZone(secondsFromGMT: 0)!) + local.record(keys: 2, at: date("2026-09-10T10:00:00Z")) + var remote = HourlyStats(startedAt: date("2026-09-10T00:00:00Z"), timeZone: TimeZone(identifier: "Asia/Kolkata")!) + // 10:00 in Kolkata: compare with the local 10:00 bucket, without splitting counts. + remote.record(keys: 5, at: date("2026-09-10T04:30:00Z")) + let series = DisplayStatsAggregator.hourlySeries(local: local, + remote: [snapshot(remote.syncShards(now: now)["2026-09-10"])], currentDeviceId: "local", + date: now, recent24Hours: false, now: now) + XCTAssertEqual(series.total[10].counts?.keys, 7) + XCTAssertEqual(series.total[4].counts?.keys, 0) + } + + func testMalformedHourlyImportRejectsNegativeCountersAndInvalidZone() throws { + var hourly = HourlyStats(startedAt: date("2026-09-10T00:00:00Z"), timeZone: TimeZone(secondsFromGMT: 0)!) + hourly.record(keys: 1, at: date("2026-09-10T10:00:00Z")) + let data = try SyncJSON.encoder.encode(hourly) + let json = String(decoding: data, as: UTF8.self) + let negative = json.replacingOccurrences(of: "\"keys\":1", with: "\"keys\":-1") + XCTAssertThrowsError(try SyncJSON.decoder.decode(HourlyStats.self, from: Data(negative.utf8))) + let invalidZone = json.replacingOccurrences(of: hourly.timeZoneIdentifier, with: "Not/A_TimeZone") + XCTAssertThrowsError(try SyncJSON.decoder.decode(HourlyStats.self, from: Data(invalidZone.utf8))) + } +} + +extension HourlyStatsTests { + func testRecentWindowDoesNotReincludeRepeatedHourOutsideItsStart() throws { + let start = date("2026-10-31T00:00:00Z") + let zone = TimeZone(identifier: "America/New_York")! + let local = HourlyStats(startedAt: start, timeZone: zone) + var remote = local + remote.record(keys: 3, at: date("2026-11-01T05:30:00Z")) + remote.record(keys: 7, at: date("2026-11-01T06:30:00Z")) + // The first 01:00 is outside the rolling window, the second is inside. + let now = date("2026-11-02T05:30:00Z") + let shards = remote.syncShards(now: now).map { snapshot($0.value, day: $0.key) } + let series = DisplayStatsAggregator.hourlySeries(local: local, remote: shards, + currentDeviceId: "local", date: now, recent24Hours: true, now: now) + XCTAssertEqual(series.total.first?.date, date("2026-11-01T06:00:00Z")) + XCTAssertEqual(series.total.first?.counts?.keys, 7) + } +} From fc9829f8754a7962412bfc6858bf1d735afeb545 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 11 Sep 2026 15:39:03 +0800 Subject: [PATCH 4/8] fix(macos): preserve hourly statistics across reset and DST --- KeyStats/HourlyStats.swift | 95 +++++++++++++--- KeyStats/StatsManager.swift | 4 +- KeyStats/en.lproj/Localizable.strings | 2 +- KeyStats/zh-Hans.lproj/Localizable.strings | 2 +- KeyStats/zh-Hant.lproj/Localizable.strings | 2 +- KeyStatsTests/HourlyStatsTests.swift | 126 +++++++++++++++++++++ 6 files changed, 213 insertions(+), 18 deletions(-) diff --git a/KeyStats/HourlyStats.swift b/KeyStats/HourlyStats.swift index bab5b07..1421e4d 100644 --- a/KeyStats/HourlyStats.swift +++ b/KeyStats/HourlyStats.swift @@ -31,7 +31,7 @@ struct HourlyStats: Codable, Equatable { mutating func record(keys: Int = 0, clicks: Int = 0, at date: Date = Date()) { guard date >= startedAt, - let hour = calendar.dateInterval(of: .hour, for: date) else { return } + let hour = Self.hourInterval(containing: date, calendar: calendar) else { return } let key = bucketKey(hour.start) var counts = buckets[key] ?? Counts() counts.keys = saturatingNonnegativeSum([counts.keys, keys]) @@ -45,8 +45,13 @@ struct HourlyStats: Codable, Equatable { let start: Date let end: Date if recent24Hours { - guard let hour = calendar.dateInterval(of: .hour, for: now) else { return [] } - start = hour.start.addingTimeInterval(-23 * 3600) + guard let hour = Self.hourInterval(containing: now, calendar: calendar) else { return [] } + var first = hour + for _ in 0..<23 { + guard let previous = Self.hourInterval(containing: first.start.addingTimeInterval(-0.001), calendar: calendar) else { return [] } + first = previous + } + start = first.start end = hour.end } else { guard let day = calendar.dateInterval(of: .day, for: date) else { return [] } @@ -56,9 +61,10 @@ struct HourlyStats: Codable, Equatable { var result: [Point] = [] var hour = start while hour < end { - let available = hour <= availableThrough && hour.addingTimeInterval(3600) > startedAt + guard let interval = Self.hourInterval(containing: hour, calendar: calendar), interval.end > hour else { break } + let available = hour <= availableThrough && interval.end > startedAt result.append(Point(date: hour, counts: available ? (buckets[bucketKey(hour)] ?? Counts()) : nil)) - hour = hour.addingTimeInterval(3600) + hour = interval.end } return result } @@ -74,6 +80,22 @@ struct HourlyStats: Codable, Equatable { timeZoneIdentifier = try values.decode(String.self, forKey: .timeZoneIdentifier) buckets = try values.decode([String: Counts].self, forKey: .buckets) recordedThrough = try values.decodeIfPresent(Date.self, forKey: .recordedThrough) + // Older Foundation hour intervals can start before a half-hour clock + // transition. Move those legacy keys to the actual transition boundary. + for (key, counts) in buckets { + guard counts.keys >= 0, counts.clicks >= 0 else { throw SyncValidationError.invalidSnapshot } + guard let timestamp = Int64(key) else { continue } + let date = Date(timeIntervalSince1970: Double(timestamp)) + guard Self.hourInterval(containing: date, calendar: calendar)?.start != date, + let transition = calendar.timeZone.nextDaylightSavingTimeTransition(after: date), + transition < date.addingTimeInterval(3600), + calendar.dateInterval(of: .hour, for: date.addingTimeInterval(3599))?.start == date else { continue } + let replacement = bucketKey(transition) + let old = buckets[replacement] ?? Counts() + buckets[replacement] = Counts(keys: saturatingNonnegativeSum([old.keys, counts.keys]), + clicks: saturatingNonnegativeSum([old.clicks, counts.clicks])) + buckets.removeValue(forKey: key) + } try validate() } @@ -86,7 +108,7 @@ struct HourlyStats: Codable, Equatable { guard let timestamp = Int64(key), String(timestamp) == key, counts.keys >= 0, counts.clicks >= 0 else { throw SyncValidationError.invalidSnapshot } let date = Date(timeIntervalSince1970: Double(timestamp)) - guard let hour = calendar.dateInterval(of: .hour, for: date), hour.start == date, + guard let hour = Self.hourInterval(containing: date, calendar: calendar), hour.start == date, hour.end > startedAt, recordedThrough.map({ date <= $0 }) ?? true, day.map({ dayKey(date) == $0 }) ?? true else { throw SyncValidationError.invalidSnapshot } } @@ -99,15 +121,20 @@ struct HourlyStats: Codable, Equatable { dayKey(Date(timeIntervalSince1970: Double(Int64(key) ?? 0))) } var result: [String: HourlyStats] = [:] - let currentDay = dayKey(now) - for day in Set(grouped.keys).union([currentDay]) { + // Retain known zero days as well as days with events. Local points use + // this same continuous recording period, so archives must preserve it. + var days: [String: DateInterval] = [:] + var cursor = startedAt + while cursor <= now, let interval = calendar.dateInterval(of: .day, for: cursor), interval.end > cursor { + days[dayKey(cursor)] = interval + cursor = interval.end + } + for (day, interval) in days { var shard = self shard.buckets = Dictionary(uniqueKeysWithValues: (grouped[day] ?? []).compactMap { key in buckets[key].map { (key, $0) } }) - let sample = shard.buckets.keys.first.flatMap(Int64.init).map { Date(timeIntervalSince1970: Double($0)) } ?? now - let end = calendar.dateInterval(of: .day, for: sample)?.end ?? now - shard.recordedThrough = max(startedAt, min(now, end.addingTimeInterval(-0.001))) + shard.recordedThrough = max(startedAt, min(now, interval.end.addingTimeInterval(-0.001))) result[day] = shard } return result @@ -162,13 +189,23 @@ struct HourlyStats: Codable, Equatable { let source = points(on: sourceDate, recent24Hours: false, now: now) guard let firstIndex = indices.first, let targetDay = displayCalendar.dateInterval(of: .day, for: target[firstIndex].date) else { continue } - let fullDay = stride(from: targetDay.start.timeIntervalSince1970, - to: targetDay.end.timeIntervalSince1970, by: 3600).map { Date(timeIntervalSince1970: $0) } + var fullDay: [Date] = [] + var cursor = targetDay.start + while cursor < targetDay.end { + guard let interval = Self.hourInterval(containing: cursor, calendar: displayCalendar), interval.end > cursor else { break } + fullDay.append(cursor) + cursor = interval.end + } let targetHours = Dictionary(grouping: fullDay) { displayCalendar.component(.hour, from: $0) } let visibleIndices = Dictionary(uniqueKeysWithValues: indices.map { (target[$0].date, $0) }) let sourceHours = Dictionary(grouping: source) { calendar.component(.hour, from: $0.date) } for (hour, sourcePoints) in sourceHours { - guard let slots = targetHours[hour] else { continue } + // A remote clock hour may not exist on this device's spring + // transition day. Preserve its counts in the next valid slot + // (or the last slot when the jump ends the local day). + let destinationHour = targetHours[hour] != nil ? hour + : (targetHours.keys.filter { $0 > hour }.min() ?? targetHours.keys.max()) + guard let destinationHour, let slots = targetHours[destinationHour] else { continue } for (occurrence, point) in sourcePoints.enumerated() { guard let counts = point.counts else { continue } // Preserve repeated hours when present on both devices; otherwise @@ -183,6 +220,36 @@ struct HourlyStats: Codable, Equatable { return result } + /// Clear only the requested local calendar day; rollover never calls this. + mutating func reset(on date: Date, calendar resetCalendar: Calendar? = nil) { + guard let day = (resetCalendar ?? calendar).dateInterval(of: .day, for: date) else { return } + buckets = buckets.filter { key, _ in + guard let timestamp = Int64(key) else { return false } + let start = Date(timeIntervalSince1970: Double(timestamp)) + return start < day.start || start >= day.end + } + } + + /// Wall-clock hours split at offset transitions, including 30-minute DST + /// changes. Foundation's hour interval can overlap its predecessor there. + private static func hourInterval(containing date: Date, calendar: Calendar) -> DateInterval? { + let zone = calendar.timeZone + let offset = Double(zone.secondsFromGMT(for: date)) + let timestamp = date.timeIntervalSince1970 + guard timestamp.isFinite else { return nil } + let nominalStart = Date(timeIntervalSince1970: floor((timestamp + offset) / 3600) * 3600 - offset) + var start = nominalStart + var end = nominalStart.addingTimeInterval(3600) + if let transition = zone.nextDaylightSavingTimeTransition(after: nominalStart.addingTimeInterval(-0.001)), transition <= date { + start = max(start, transition) + } + if let transition = zone.nextDaylightSavingTimeTransition(after: date), transition < end { + end = transition + } + guard start <= date, date < end else { return nil } + return DateInterval(start: start, end: end) + } + private func dayKey(_ date: Date) -> String { let parts = calendar.dateComponents([.year, .month, .day], from: date) return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index d1f553c..624e5c0 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -1302,8 +1302,10 @@ class StatsManager { } func resetStats() { + let now = Date() statsStateLock.lock() - resetStatsLocked(for: Date()) + resetStatsLocked(for: now) + hourlyStats.reset(on: now, calendar: .current) statsStateLock.unlock() updateNotificationBaselines() notifyMenuBarUpdate() diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index 1a55f7e..4cb219e 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -366,7 +366,7 @@ "hourly.empty" = "No hourly data for this date"; "hourly.unavailable" = "No hourly data"; "hourly.summary" = "Recorded totals: %ld keys · %ld clicks · Busiest hour: %@"; -"hourly.note" = "This Mac records since %@ · %@\nTotals compare each device’s local date and hour, using its latest synced hourly data. First/current hours may be partial; Last 24 Hours includes the current hour."; +"hourly.note" = "This Mac records since %@ · %@\nTotals compare each device’s local date and hour, using its latest synced hourly data. First/current hours may be partial; Last 24 Hours includes the current hour. Hours skipped by DST are included in an adjacent valid hour."; "hourly.hover" = "Hover to compare this Mac with the synced total for the selected metric."; "hourly.detail" = "%@ · %ld keys · %ld clicks"; "hourly.chartAccessibility" = "Hourly counts for the selected metric: synced total (solid blue), this Mac (dashed orange). Each hour lists total / local."; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index 4ec3908..7e37a28 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -366,7 +366,7 @@ "hourly.empty" = "该日期暂无小时数据"; "hourly.unavailable" = "暂无小时数据"; "hourly.summary" = "已记录合计:按键 %ld · 点击 %ld · 最活跃时段:%@"; -"hourly.note" = "本机从 %@ 开始记录 · %@\n总数按各设备当地日期和小时汇总,以最近同步的小时数据为准。起始和当前小时可能不完整;最近 24 小时包含当前小时。"; +"hourly.note" = "本机从 %@ 开始记录 · %@\n总数按各设备当地日期和小时汇总,以最近同步的小时数据为准。起始和当前小时可能不完整;最近 24 小时包含当前小时。夏令时跳过的小时并入相邻有效时段。"; "hourly.hover" = "将鼠标移至折线图,比较当前指标的本设备次数和总数。"; "hourly.detail" = "%@ · 按键 %ld · 点击 %ld"; "hourly.chartAccessibility" = "所选指标每小时次数:总数为蓝色实线,本设备为橙色虚线。每个小时依次列出总数和本设备次数。"; diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings index fdedbc7..5e763fb 100644 --- a/KeyStats/zh-Hant.lproj/Localizable.strings +++ b/KeyStats/zh-Hant.lproj/Localizable.strings @@ -366,7 +366,7 @@ "hourly.empty" = "該日期暫無小時資料"; "hourly.unavailable" = "暫無小時資料"; "hourly.summary" = "已記錄合計:按鍵 %ld · 點擊 %ld · 最活躍時段:%@"; -"hourly.note" = "本機從 %@ 開始記錄 · %@\n總數按各裝置當地日期和小時彙總,以最近同步的小時資料為準。起始和目前小時可能不完整;最近 24 小時包含目前小時。"; +"hourly.note" = "本機從 %@ 開始記錄 · %@\n總數按各裝置當地日期和小時彙總,以最近同步的小時資料為準。起始和目前小時可能不完整;最近 24 小時包含目前小時。夏令時跳過的小時併入相鄰有效時段。"; "hourly.hover" = "將滑鼠移至折線圖,比較目前指標的本裝置次數和總數。"; "hourly.detail" = "%@ · 按鍵 %ld · 點擊 %ld"; "hourly.chartAccessibility" = "所選指標每小時次數:總數為藍色實線,本裝置為橙色虛線。每個小時依次列出總數和本裝置次數。"; diff --git a/KeyStatsTests/HourlyStatsTests.swift b/KeyStatsTests/HourlyStatsTests.swift index ef4b825..0ce454a 100644 --- a/KeyStatsTests/HourlyStatsTests.swift +++ b/KeyStatsTests/HourlyStatsTests.swift @@ -222,3 +222,129 @@ extension HourlyStatsTests { XCTAssertEqual(series.total.first?.counts?.keys, 7) } } + +extension HourlyStatsTests { + func testManualHourlyResetPreservesYesterdayAndPublishesZeroSnapshot() throws { + let yesterday = date("2026-09-09T10:00:00Z") + let today = date("2026-09-10T10:00:00Z") + var hourly = HourlyStats(startedAt: yesterday, timeZone: TimeZone(secondsFromGMT: 0)!) + hourly.record(keys: 8, clicks: 2, at: yesterday) + hourly.record(keys: 5, clicks: 3, at: today) + let before = snapshot(hourly.syncShards(now: today)["2026-09-10"]) + hourly.reset(on: today) + let restored = try SyncJSON.decoder.decode(HourlyStats.self, from: SyncJSON.encoder.encode(hourly)) + XCTAssertEqual(restored.points(on: yesterday, recent24Hours: false, now: today)[10].counts, + HourlyStats.Counts(keys: 8, clicks: 2)) + XCTAssertEqual(restored.points(on: today, recent24Hours: false, now: today)[10].counts, + HourlyStats.Counts()) + let after = snapshot(restored.syncShards(now: today)["2026-09-10"], revision: 2) + XCTAssertNotEqual(try SyncCrypto.contentHash(before), try SyncCrypto.contentHash(after)) + let record = try SyncCrypto.encrypt(snapshot: after, vaultId: "vault", seed: Data(0..<16)) + let remote = try SyncCrypto.decrypt(record: record, vaultId: "vault", seed: Data(0..<16)) + XCTAssertEqual(remote.hourlyStats?.points(on: today, recent24Hours: false, now: today)[10].counts, + HourlyStats.Counts()) + hourly.record(keys: 1, at: today) + XCTAssertEqual(hourly.points(on: today, recent24Hours: false, now: today)[10].counts?.keys, 1) + } + + func testHalfHourSpringTransitionUsesSameBucketsForRecordingAndBothViews() throws { + let zone = TimeZone(identifier: "Australia/Lord_Howe")! + let start = date("2026-10-03T00:00:00Z") + let partialHour = date("2026-10-03T15:45:00Z") // 02:45 after the jump + let regularHour = date("2026-10-03T16:15:00Z") // 03:15 + var hourly = HourlyStats(startedAt: start, timeZone: zone) + hourly.record(keys: 7, at: partialHour) + hourly.record(keys: 9, at: regularHour) + let daily = hourly.points(on: regularHour, recent24Hours: false, now: regularHour) + XCTAssertEqual(daily.reduce(0) { $0 + ($1.counts?.keys ?? 0) }, 16) + XCTAssertEqual(daily.first { $0.date == date("2026-10-03T15:30:00Z") }?.counts?.keys, 7) + XCTAssertEqual(daily.first { $0.date == date("2026-10-03T16:00:00Z") }?.counts?.keys, 9) + let recent = hourly.points(on: regularHour, recent24Hours: true, now: regularHour) + XCTAssertEqual(recent.count, 24) + XCTAssertEqual(recent.reduce(0) { $0 + ($1.counts?.keys ?? 0) }, 16) + let restored = try SyncJSON.decoder.decode(HourlyStats.self, from: SyncJSON.encoder.encode(hourly)) + XCTAssertEqual(restored, hourly) + for (day, shard) in hourly.syncShards(now: regularHour) { try shard.validate(day: day) } + } + + func testHalfHourFallTransitionPreservesBothRepeatedHours() throws { + var hourly = HourlyStats(startedAt: date("2026-04-03T00:00:00Z"), + timeZone: TimeZone(identifier: "Australia/Lord_Howe")!) + hourly.record(keys: 3, at: date("2026-04-04T14:45:00Z")) // first 01:45 + hourly.record(keys: 7, at: date("2026-04-04T15:15:00Z")) // second 01:45 + let now = date("2026-04-04T16:00:00Z") + let points = hourly.points(on: now, recent24Hours: false, now: now) + XCTAssertEqual(points.first { $0.date == date("2026-04-04T14:00:00Z") }?.counts?.keys, 3) + XCTAssertEqual(points.first { $0.date == date("2026-04-04T15:00:00Z") }?.counts?.keys, 7) + XCTAssertEqual(points.reduce(0) { $0 + ($1.counts?.keys ?? 0) }, 10) + let restored = try SyncJSON.decoder.decode(HourlyStats.self, from: SyncJSON.encoder.encode(hourly)) + XCTAssertEqual(restored, hourly) + } + + func testLegacyHalfHourTransitionBucketMigratesWithoutLosingCounts() throws { + var hourly = HourlyStats(startedAt: date("2026-10-03T00:00:00Z"), + timeZone: TimeZone(identifier: "Australia/Lord_Howe")!) + let event = date("2026-10-03T15:45:00Z") + hourly.record(keys: 7, at: event) + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: SyncJSON.encoder.encode(hourly)) as? [String: Any]) + let legacyStart = try XCTUnwrap(hourly.calendar.dateInterval(of: .hour, for: event)?.start) + json["buckets"] = [String(Int64(legacyStart.timeIntervalSince1970)): ["keys": 7, "clicks": 0]] + let restored = try SyncJSON.decoder.decode(HourlyStats.self, from: JSONSerialization.data(withJSONObject: json)) + XCTAssertEqual(restored, hourly) + json["buckets"] = [String(Int64(legacyStart.timeIntervalSince1970)): ["keys": -1, "clicks": 0]] + XCTAssertThrowsError(try SyncJSON.decoder.decode(HourlyStats.self, from: JSONSerialization.data(withJSONObject: json))) + } + + func testRemoteSkippedClockHourIsIncludedInNextValidHour() throws { + let start = date("2026-03-01T00:00:00Z") + let local = HourlyStats(startedAt: start, timeZone: TimeZone(identifier: "America/New_York")!) + var remote = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + remote.record(keys: 7, clicks: 2, at: date("2026-03-08T02:30:00Z")) + remote.record(keys: 5, clicks: 1, at: date("2026-03-08T03:30:00Z")) + let now = date("2026-03-09T12:00:00Z") + let shards = remote.syncShards(now: now).map { snapshot($0.value, day: $0.key) } + let series = DisplayStatsAggregator.hourlySeries(local: local, remote: shards, currentDeviceId: "local", + date: date("2026-03-08T12:00:00Z"), recent24Hours: false, now: now) + XCTAssertEqual(series.total.reduce(0) { $0 + ($1.counts?.keys ?? 0) }, 12) + let three = series.total.first { local.calendar.component(.hour, from: $0.date) == 3 } + XCTAssertEqual(three?.counts, HourlyStats.Counts(keys: 12, clicks: 3)) + } + + func testZeroActivityDayRemainsAnArchiveAfterRolloverAndCacheReload() throws { + let start = date("2026-09-09T00:00:00Z") + let hourly = HourlyStats(startedAt: start, timeZone: TimeZone(secondsFromGMT: 0)!) + let first = snapshot(hourly.syncShards(now: date("2026-09-09T12:00:00Z"))["2026-09-09"], day: "2026-09-09") + let next = try XCTUnwrap(hourly.syncShards(now: date("2026-09-10T12:00:00Z"))["2026-09-09"]) + let later = try XCTUnwrap(hourly.syncShards(now: date("2026-09-11T12:00:00Z"))["2026-09-09"]) + XCTAssertEqual(next, later) + XCTAssertEqual(next.recordedThrough, date("2026-09-10T00:00:00Z").addingTimeInterval(-0.001)) + let path = FileManager.default.temporaryDirectory.appendingPathComponent("zero-day-cache-\(UUID().uuidString).json") + let cache = RemoteShardCache(fileURL: path) + _ = try cache.apply(recordId: "record", snapshot: first, currentDeviceId: "local") + _ = try cache.apply(recordId: "record", snapshot: snapshot(next, day: "2026-09-09", revision: 2), currentDeviceId: "local") + let restored = RemoteShardCache(fileURL: path) + let local = HourlyStats(startedAt: date("2026-09-10T00:00:00Z"), timeZone: TimeZone(secondsFromGMT: 0)!) + let series = DisplayStatsAggregator.hourlySeries(local: local, remote: restored.snapshots(), currentDeviceId: "local", + date: start, recent24Hours: false, now: date("2026-09-11T12:00:00Z")) + XCTAssertTrue(series.local.allSatisfy { $0.counts == nil }) + XCTAssertTrue(series.total.allSatisfy { $0.counts == HourlyStats.Counts() }) + XCTAssertNil(hourly.syncShards(now: date("2026-09-10T12:00:00Z"))["2026-09-08"]) + } +} + +extension HourlyStatsTests { + func testResetUsesTheDailyStatisticsCalendarAfterTravel() { + var hourly = HourlyStats(startedAt: date("2026-09-09T00:00:00Z"), + timeZone: TimeZone(identifier: "Asia/Shanghai")!) + let events = ["2026-09-09T23:00:00Z", "2026-09-10T10:00:00Z", "2026-09-10T20:00:00Z", "2026-09-11T01:00:00Z"].map(date) + for event in events { hourly.record(keys: 1, at: event) } + var dailyCalendar = Calendar(identifier: .gregorian) + dailyCalendar.timeZone = TimeZone(secondsFromGMT: 0)! + hourly.reset(on: date("2026-09-10T23:00:00Z"), calendar: dailyCalendar) + let now = date("2026-09-12T00:00:00Z") + for (index, event) in events.enumerated() { + let point = hourly.points(on: event, recent24Hours: false, now: now).first { $0.date == event } + XCTAssertEqual(point?.counts?.keys, index == 0 || index == 3 ? 1 : 0) + } + } +} From 48bb99f6d4000be39daa47b35d74eddfa09dd055 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 11 Sep 2026 17:28:22 +0800 Subject: [PATCH 5/8] fix(hourly): tighten chart legend spacing --- KeyStats/HourlyStatsChartView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/KeyStats/HourlyStatsChartView.swift b/KeyStats/HourlyStatsChartView.swift index fe07b5e..922b1c4 100644 --- a/KeyStats/HourlyStatsChartView.swift +++ b/KeyStats/HourlyStatsChartView.swift @@ -88,8 +88,10 @@ final class HourlyStatsChartView: NSView { } let keyLabel = NSLocalizedString("history.series.synced", comment: "") let clickLabel = NSLocalizedString("history.series.local", comment: "") - label("● " + keyLabel, at: NSPoint(x: rect.minX, y: bounds.height - 22), color: .systemBlue) - label("◆ " + clickLabel, at: NSPoint(x: rect.minX + 160, y: bounds.height - 22), color: .systemOrange) + let syncedLegend = "● " + keyLabel + let syncedLegendWidth = syncedLegend.size(withAttributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)]).width + label(syncedLegend, at: NSPoint(x: rect.minX, y: bounds.height - 22), color: .systemBlue) + label("◆ " + clickLabel, at: NSPoint(x: rect.minX + syncedLegendWidth + 16, y: bounds.height - 22), color: .systemOrange) guard !points.isEmpty else { return } for index in points.indices where index % 3 == 0 || index == points.count - 1 { // Avoid colliding with the last tick on 23/25-hour daylight-saving days. From 53a32863d221330839f1868a7f3f648008ba0918 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 11 Sep 2026 17:35:02 +0800 Subject: [PATCH 6/8] fix(hourly): show hover values and smooth trend curves --- KeyStats/HourlyStatsChartView.swift | 97 +++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 6 deletions(-) diff --git a/KeyStats/HourlyStatsChartView.swift b/KeyStats/HourlyStatsChartView.swift index 922b1c4..ccb9b33 100644 --- a/KeyStats/HourlyStatsChartView.swift +++ b/KeyStats/HourlyStatsChartView.swift @@ -86,8 +86,8 @@ final class HourlyStatsChartView: NSView { label(String(format: "%.0f", Double(maximum) * Double(step) / 4), at: NSPoint(x: 4, y: y - 6), color: .secondaryLabelColor) } - let keyLabel = NSLocalizedString("history.series.synced", comment: "") - let clickLabel = NSLocalizedString("history.series.local", comment: "") + let keyLabel = legendLabel(title: NSLocalizedString("history.series.synced", comment: ""), series: points) + let clickLabel = legendLabel(title: NSLocalizedString("history.series.local", comment: ""), series: localPoints) let syncedLegend = "● " + keyLabel let syncedLegendWidth = syncedLegend.size(withAttributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)]).width label(syncedLegend, at: NSPoint(x: rect.minX, y: bounds.height - 22), color: .systemBlue) @@ -116,12 +116,16 @@ final class HourlyStatsChartView: NSView { private func drawSeries(_ series: [HourlyStats.Point], color: NSColor, maximum: Int, dashed: Bool) { let path = NSBezierPath() - var previous: NSPoint? + var segment: [NSPoint] = [] for (index, point) in series.enumerated() { - guard let counts = point.counts else { previous = nil; continue } + guard let counts = point.counts else { + appendSmoothSegment(segment, to: path) + segment.removeAll(keepingCapacity: true) + continue + } let value = value(counts) let position = NSPoint(x: x(index), y: plotRect.minY + plotRect.height * CGFloat(value) / CGFloat(maximum)) - if previous != nil { path.line(to: position) } else { path.move(to: position) } + segment.append(position) color.setFill() if dashed { let diamond = NSBezierPath() @@ -134,14 +138,95 @@ final class HourlyStatsChartView: NSView { } else { NSBezierPath(ovalIn: NSRect(x: position.x - 2.5, y: position.y - 2.5, width: 5, height: 5)).fill() } - previous = position } + appendSmoothSegment(segment, to: path) color.setStroke() path.lineWidth = 2 + path.lineJoinStyle = .round + path.lineCapStyle = .round if dashed { path.setLineDash([5, 3], count: 2, phase: 0) } path.stroke() } + private func legendLabel(title: String, series: [HourlyStats.Point]) -> String { + guard let hoveredPoint else { return title } + let counts = series.first(where: { $0.date == hoveredPoint.date })?.counts + let text = counts.map { String(value($0)) } ?? "—" + return String(format: NSLocalizedString("history.series.value", comment: ""), title, text) + } + + private func appendSmoothSegment(_ points: [NSPoint], to path: NSBezierPath) { + guard let first = points.first else { return } + path.move(to: first) + let tangents = monotoneTangents(for: points) + for index in 0..<(points.count - 1) { + let current = points[index] + let next = points[index + 1] + let dx = next.x - current.x + path.curve(to: next, + controlPoint1: NSPoint(x: current.x + dx / 3, y: current.y + tangents[index] * dx / 3), + controlPoint2: NSPoint(x: next.x - dx / 3, y: next.y - tangents[index + 1] * dx / 3)) + } + } + + // Same shape-preserving interpolation as the main trend chart, applied separately across gaps. + private func monotoneTangents(for points: [NSPoint]) -> [CGFloat] { + let count = points.count + guard count > 1 else { return [0] } + + var dx = Array(repeating: CGFloat(0), count: count - 1) + var slopes = Array(repeating: CGFloat(0), count: count - 1) + + for index in 0..<(count - 1) { + let deltaX = max(0.0001, points[index + 1].x - points[index].x) + dx[index] = deltaX + slopes[index] = (points[index + 1].y - points[index].y) / deltaX + } + + var tangents = Array(repeating: CGFloat(0), count: count) + tangents[0] = slopes[0] + tangents[count - 1] = slopes[count - 2] + + if count > 2 { + for index in 1..<(count - 1) { + let previousSlope = slopes[index - 1] + let nextSlope = slopes[index] + + if previousSlope == 0 || nextSlope == 0 || (previousSlope > 0) != (nextSlope > 0) { + tangents[index] = 0 + continue + } + + let previousDX = dx[index - 1] + let nextDX = dx[index] + let weight1 = 2 * nextDX + previousDX + let weight2 = nextDX + 2 * previousDX + tangents[index] = (weight1 + weight2) / ((weight1 / previousSlope) + (weight2 / nextSlope)) + } + } + + for index in 0..<(count - 1) { + let slope = slopes[index] + if slope == 0 { + tangents[index] = 0 + tangents[index + 1] = 0 + continue + } + + let alpha = tangents[index] / slope + let beta = tangents[index + 1] / slope + let magnitude = alpha * alpha + beta * beta + + if magnitude > 9 { + let scale = CGFloat(3.0 / sqrt(Double(magnitude))) + tangents[index] = scale * alpha * slope + tangents[index + 1] = scale * beta * slope + } + } + + return tangents + } + private func stroke(from start: NSPoint, to end: NSPoint, color: NSColor, width: CGFloat) { let path = NSBezierPath() path.move(to: start) From 59588c7759e7e55b2bb95cc9f3779b732d1ccb30 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 11 Sep 2026 17:36:38 +0800 Subject: [PATCH 7/8] feat(hourly): animate date changes like keyboard heatmap --- KeyStats/HourlyStatsViewController.swift | 149 ++++++++++++++++++++--- 1 file changed, 132 insertions(+), 17 deletions(-) diff --git a/KeyStats/HourlyStatsViewController.swift b/KeyStats/HourlyStatsViewController.swift index 5804a49..79a0e17 100644 --- a/KeyStats/HourlyStatsViewController.swift +++ b/KeyStats/HourlyStatsViewController.swift @@ -1,6 +1,22 @@ import Cocoa final class HourlyStatsViewController: NSViewController { + private enum DaySlideDirection { + case left + case right + + func incomingOffset(distance: CGFloat) -> CGFloat { + switch self { + case .left: return -distance + case .right: return distance + } + } + + func outgoingOffset(distance: CGFloat) -> CGFloat { + -incomingOffset(distance: distance) + } + } + private let datePickerButton = NSButton() private let backToTodayButton = NSButton() private var selectedDate = Date() @@ -8,9 +24,9 @@ final class HourlyStatsViewController: NSViewController { let controller = HeatmapDatePickerPopoverViewController() controller.onDateSelected = { [weak self] date in guard let self else { return } - self.selectedDate = date self.datePickerPopover.performClose(nil) - self.dateChanged() + AppDelegate.trackClick("hourly_date") + self.transitionToDate(date) } return controller }() @@ -28,6 +44,8 @@ final class HourlyStatsViewController: NSViewController { private let detail = NSTextField(labelWithString: "") private let note = NSTextField(wrappingLabelWithString: "") private let chart = HourlyStatsChartView() + private let chartContainer = NSView() + private var isDayTransitionAnimating = false private var refreshTimer: Timer? private var lastDay: Date? @@ -81,7 +99,17 @@ final class HourlyStatsViewController: NSViewController { detail.textColor = .secondaryLabelColor note.font = .systemFont(ofSize: 11) note.textColor = .secondaryLabelColor - for child in [title, metric, controls, summary, chart, detail, note] { + chartContainer.wantsLayer = true + chartContainer.layer?.masksToBounds = true + chart.translatesAutoresizingMaskIntoConstraints = false + chartContainer.addSubview(chart) + NSLayoutConstraint.activate([ + chart.leadingAnchor.constraint(equalTo: chartContainer.leadingAnchor), + chart.trailingAnchor.constraint(equalTo: chartContainer.trailingAnchor), + chart.topAnchor.constraint(equalTo: chartContainer.topAnchor), + chart.bottomAnchor.constraint(equalTo: chartContainer.bottomAnchor) + ]) + for child in [title, metric, controls, summary, chartContainer, detail, note] { child.translatesAutoresizingMaskIntoConstraints = false view.addSubview(child) } @@ -98,15 +126,15 @@ final class HourlyStatsViewController: NSViewController { summary.leadingAnchor.constraint(equalTo: title.leadingAnchor), summary.topAnchor.constraint(equalTo: controls.bottomAnchor, constant: 20), summary.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), - chart.leadingAnchor.constraint(equalTo: title.leadingAnchor), - chart.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), - chart.topAnchor.constraint(equalTo: summary.bottomAnchor, constant: 12), - chart.bottomAnchor.constraint(equalTo: detail.topAnchor, constant: -10), + chartContainer.leadingAnchor.constraint(equalTo: title.leadingAnchor), + chartContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + chartContainer.topAnchor.constraint(equalTo: summary.bottomAnchor, constant: 12), + chartContainer.bottomAnchor.constraint(equalTo: detail.topAnchor, constant: -10), detail.leadingAnchor.constraint(equalTo: title.leadingAnchor), - detail.trailingAnchor.constraint(equalTo: chart.trailingAnchor), + detail.trailingAnchor.constraint(equalTo: chartContainer.trailingAnchor), detail.bottomAnchor.constraint(equalTo: note.topAnchor, constant: -12), note.leadingAnchor.constraint(equalTo: title.leadingAnchor), - note.trailingAnchor.constraint(equalTo: chart.trailingAnchor), + note.trailingAnchor.constraint(equalTo: chartContainer.trailingAnchor), note.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20) ]) chart.onHover = { [weak self] point in self?.showDetail(point) } @@ -248,10 +276,8 @@ final class HourlyStatsViewController: NSViewController { } @objc private func backToToday() { - selectedDate = StatsManager.shared.hourlyStatsSnapshot().calendar.startOfDay(for: Date()) AppDelegate.trackClick("hourly_back_to_today") - chart.clearHover() - refresh() + transitionToDate(Date()) } @objc private func metricChanged() { @@ -266,19 +292,108 @@ final class HourlyStatsViewController: NSViewController { refresh() } - @objc private func dateChanged() { - AppDelegate.trackClick("hourly_date") + private func transitionToDate(_ date: Date) { + let calendar = StatsManager.shared.hourlyStatsSnapshot().calendar + let targetDate = calendar.startOfDay(for: min(date, Date())) + guard !calendar.isDate(targetDate, inSameDayAs: selectedDate) else { return } + let direction: DaySlideDirection = targetDate < selectedDate ? .left : .right chart.clearHover() + let previousSnapshot = isDayTransitionAnimating ? nil : makeChartSnapshot() + selectedDate = targetDate refresh() + animateDayTransition(direction: direction, previousSnapshot: previousSnapshot) } private func moveDay(_ offset: Int) { let calendar = StatsManager.shared.hourlyStatsSnapshot().calendar guard let date = calendar.date(byAdding: .day, value: offset, to: selectedDate) else { return } - selectedDate = min(date, Date()) AppDelegate.trackClick("hourly_day", properties: ["direction": offset < 0 ? "previous" : "next"]) - chart.clearHover() - refresh() + transitionToDate(date) + } + + private func makeChartSnapshot() -> NSImageView? { + guard chartContainer.bounds.width > 0, chartContainer.bounds.height > 0 else { return nil } + chartContainer.layoutSubtreeIfNeeded() + + let bounds = chartContainer.bounds + guard let bitmap = chartContainer.bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + chartContainer.cacheDisplay(in: bounds, to: bitmap) + + let image = NSImage(size: bounds.size) + image.addRepresentation(bitmap) + + let imageView = NSImageView(image: image) + imageView.frame = bounds + imageView.imageScaling = .scaleAxesIndependently + imageView.autoresizingMask = [.width, .height] + imageView.wantsLayer = true + return imageView + } + + private func animateDayTransition(direction: DaySlideDirection, previousSnapshot: NSImageView?) { + guard !isDayTransitionAnimating else { + previousSnapshot?.removeFromSuperview() + return + } + guard let containerLayer = chartContainer.layer else { + previousSnapshot?.removeFromSuperview() + return + } + + isDayTransitionAnimating = true + let duration: CFTimeInterval = 0.16 + let slideDistance = max(260, chartContainer.bounds.width * 0.88) + let timingFunction = CAMediaTimingFunction(name: .easeOut) + + if let previousSnapshot { + chartContainer.addSubview(previousSnapshot, positioned: .above, relativeTo: nil) + } + + CATransaction.begin() + CATransaction.setAnimationDuration(duration) + CATransaction.setAnimationTimingFunction(timingFunction) + CATransaction.setCompletionBlock { [weak self] in + previousSnapshot?.removeFromSuperview() + self?.isDayTransitionAnimating = false + } + + if let previousLayer = previousSnapshot?.layer { + let outgoingAnimation = CAAnimationGroup() + outgoingAnimation.animations = [ + basicAnimation(keyPath: "transform.translation.x", from: 0, to: direction.outgoingOffset(distance: slideDistance)), + basicAnimation(keyPath: "opacity", from: 1, to: 0) + ] + outgoingAnimation.duration = duration + outgoingAnimation.timingFunction = timingFunction + previousLayer.add(outgoingAnimation, forKey: "day-slide-out") + previousLayer.opacity = 0 + } + + let incomingTargets: [NSView] = [chart] + for view in incomingTargets { + view.wantsLayer = true + guard let layer = view.layer else { continue } + let targetOpacity = max(0, min(1, view.alphaValue)) + layer.removeAnimation(forKey: "day-slide-in") + let incomingAnimation = CAAnimationGroup() + incomingAnimation.animations = [ + basicAnimation(keyPath: "transform.translation.x", from: direction.incomingOffset(distance: slideDistance), to: 0), + basicAnimation(keyPath: "opacity", from: 0, to: targetOpacity) + ] + incomingAnimation.duration = duration + incomingAnimation.timingFunction = timingFunction + layer.add(incomingAnimation, forKey: "day-slide-in") + } + + containerLayer.layoutIfNeeded() + CATransaction.commit() + } + + private func basicAnimation(keyPath: String, from: CGFloat, to: CGFloat) -> CABasicAnimation { + let animation = CABasicAnimation(keyPath: keyPath) + animation.fromValue = from + animation.toValue = to + return animation } @objc private func previousDay() { moveDay(-1) } From 05b455ff5faf54d98c13d37e5100229c307a6f52 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 11 Sep 2026 17:39:28 +0800 Subject: [PATCH 8/8] fix(hourly): refine empty state presentation --- KeyStats/HourlyStatsChartView.swift | 44 ++++++++++++++++++++-- KeyStats/HourlyStatsViewController.swift | 4 +- KeyStats/en.lproj/Localizable.strings | 1 + KeyStats/zh-Hans.lproj/Localizable.strings | 1 + KeyStats/zh-Hant.lproj/Localizable.strings | 1 + 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/KeyStats/HourlyStatsChartView.swift b/KeyStats/HourlyStatsChartView.swift index ccb9b33..4215ca2 100644 --- a/KeyStats/HourlyStatsChartView.swift +++ b/KeyStats/HourlyStatsChartView.swift @@ -5,6 +5,11 @@ final class HourlyStatsChartView: NSView { var points: [HourlyStats.Point] = [] { didSet { needsDisplay = true + if !hasData { + clearHover() + setAccessibilityValue(NSLocalizedString("hourly.empty", comment: "")) + return + } setAccessibilityValue(points.enumerated().map { index, point in let total = point.counts.map { String(value($0)) } ?? "—" let local = localPoints.indices.contains(index) ? localPoints[index].counts.map { String(value($0)) } ?? "—" : "—" @@ -19,6 +24,8 @@ final class HourlyStatsChartView: NSView { private var hoverIndex: Int? private var area: NSTrackingArea? + var hasData: Bool { points.contains { $0.counts != nil } } + var hoveredPoint: HourlyStats.Point? { guard let hoverIndex, points.indices.contains(hoverIndex) else { return nil } return points[hoverIndex] @@ -48,7 +55,7 @@ final class HourlyStatsChartView: NSView { override func mouseMoved(with event: NSEvent) { let position = convert(event.locationInWindow, from: nil) - guard plotRect.contains(position), !points.isEmpty else { clearHover(); return } + guard plotRect.contains(position), hasData else { clearHover(); return } let fraction = (position.x - plotRect.minX) / plotRect.width hoverIndex = min(points.count - 1, max(0, Int((fraction * CGFloat(points.count - 1)).rounded()))) needsDisplay = true @@ -78,6 +85,10 @@ final class HourlyStatsChartView: NSView { super.draw(dirtyRect) NSColor.controlBackgroundColor.withAlphaComponent(0.5).setFill() NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10).fill() + guard hasData else { + drawEmptyState() + return + } let rect = plotRect let maximum = max(4, points.reduce(0) { max($0, $1.counts.map(value) ?? 0) }) for step in 0...4 { @@ -100,14 +111,39 @@ final class HourlyStatsChartView: NSView { } drawSeries(points, color: .systemBlue, maximum: maximum, dashed: false) drawSeries(localPoints, color: .systemOrange, maximum: maximum, dashed: true) - if points.allSatisfy({ $0.counts == nil }) { - label(NSLocalizedString("hourly.empty", comment: ""), at: NSPoint(x: rect.minX + 16, y: rect.midY), color: .secondaryLabelColor) - } if let hoverIndex, points.indices.contains(hoverIndex) { stroke(from: NSPoint(x: x(hoverIndex), y: rect.minY), to: NSPoint(x: x(hoverIndex), y: rect.maxY), color: .secondaryLabelColor, width: 1) } } + private func drawEmptyState() { + let center = NSPoint(x: bounds.midX, y: bounds.midY) + let badge = NSRect(x: center.x - 26, y: center.y + 6, width: 52, height: 52) + NSColor.systemBlue.withAlphaComponent(0.08).setFill() + NSBezierPath(roundedRect: badge, xRadius: 16, yRadius: 16).fill() + let symbol = NSImage(systemSymbolName: "clock", accessibilityDescription: nil)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(paletteColors: [.systemBlue])) + symbol?.draw(in: badge.insetBy(dx: 13, dy: 13)) + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let textWidth = max(1, min(420, bounds.width - 48)) + let textX = center.x - textWidth / 2 + let titleAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 14, weight: .medium), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph + ] + NSLocalizedString("hourly.empty", comment: "").draw( + in: NSRect(x: textX, y: center.y - 30, width: textWidth, height: 22), + withAttributes: titleAttributes) + NSLocalizedString("hourly.empty.hint", comment: "").draw( + in: NSRect(x: textX, y: center.y - 70, width: textWidth, height: 34), + withAttributes: [.font: NSFont.systemFont(ofSize: 12), + .foregroundColor: NSColor.secondaryLabelColor, + .paragraphStyle: paragraph]) + } + private func x(_ index: Int) -> CGFloat { plotRect.minX + plotRect.width * CGFloat(index) / CGFloat(max(1, points.count - 1)) } diff --git a/KeyStats/HourlyStatsViewController.swift b/KeyStats/HourlyStatsViewController.swift index 79a0e17..9152f04 100644 --- a/KeyStats/HourlyStatsViewController.swift +++ b/KeyStats/HourlyStatsViewController.swift @@ -182,7 +182,7 @@ final class HourlyStatsViewController: NSViewController { chart.points = points let recorded = points.compactMap(\.counts) if recorded.isEmpty { - summary.stringValue = NSLocalizedString("hourly.empty", comment: "") + summary.stringValue = "" } else { let total = saturatingNonnegativeSum(recorded.map(chart.value)) let local = saturatingNonnegativeSum(series.local.compactMap(\.counts).map(chart.value)) @@ -225,7 +225,7 @@ final class HourlyStatsViewController: NSViewController { private func showDetail(_ point: HourlyStats.Point?) { guard let point else { - detail.stringValue = NSLocalizedString("hourly.hover", comment: "") + detail.stringValue = chart.hasData ? NSLocalizedString("hourly.hover", comment: "") : "" return } let formatter = DateFormatter() diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index 4cb219e..fa228b1 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -364,6 +364,7 @@ "hourly.previous" = "Previous day"; "hourly.next" = "Next day"; "hourly.empty" = "No hourly data for this date"; +"hourly.empty.hint" = "Choose another date to view hourly activity."; "hourly.unavailable" = "No hourly data"; "hourly.summary" = "Recorded totals: %ld keys · %ld clicks · Busiest hour: %@"; "hourly.note" = "This Mac records since %@ · %@\nTotals compare each device’s local date and hour, using its latest synced hourly data. First/current hours may be partial; Last 24 Hours includes the current hour. Hours skipped by DST are included in an adjacent valid hour."; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index 7e37a28..9edee6b 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -364,6 +364,7 @@ "hourly.previous" = "前一天"; "hourly.next" = "后一天"; "hourly.empty" = "该日期暂无小时数据"; +"hourly.empty.hint" = "切换其他日期,查看小时活动记录。"; "hourly.unavailable" = "暂无小时数据"; "hourly.summary" = "已记录合计:按键 %ld · 点击 %ld · 最活跃时段:%@"; "hourly.note" = "本机从 %@ 开始记录 · %@\n总数按各设备当地日期和小时汇总,以最近同步的小时数据为准。起始和当前小时可能不完整;最近 24 小时包含当前小时。夏令时跳过的小时并入相邻有效时段。"; diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings index 5e763fb..95271ae 100644 --- a/KeyStats/zh-Hant.lproj/Localizable.strings +++ b/KeyStats/zh-Hant.lproj/Localizable.strings @@ -364,6 +364,7 @@ "hourly.previous" = "前一天"; "hourly.next" = "後一天"; "hourly.empty" = "該日期暫無小時資料"; +"hourly.empty.hint" = "切換其他日期,查看小時活動記錄。"; "hourly.unavailable" = "暫無小時資料"; "hourly.summary" = "已記錄合計:按鍵 %ld · 點擊 %ld · 最活躍時段:%@"; "hourly.note" = "本機從 %@ 開始記錄 · %@\n總數按各裝置當地日期和小時彙總,以最近同步的小時資料為準。起始和目前小時可能不完整;最近 24 小時包含目前小時。夏令時跳過的小時併入相鄰有效時段。";