-
Notifications
You must be signed in to change notification settings - Fork 59
feat(macos): 新增小时趋势及备份同步 #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
debugtheworldbot
wants to merge
8
commits into
main
Choose a base branch
from
codex/macos-hourly-trends
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3712b35
feat(macos): add local hourly input trends
debugtheworldbot 8441d3a
fix(macos): match hourly date navigation to keyboard heatmap
debugtheworldbot 8b325a9
feat(macos): back up and sync hourly input trends
debugtheworldbot fc9829f
fix(macos): preserve hourly statistics across reset and DST
debugtheworldbot 48bb99f
fix(hourly): tighten chart legend spacing
debugtheworldbot 53a3286
fix(hourly): show hover values and smooth trend curves
debugtheworldbot 59588c7
feat(hourly): animate date changes like keyboard heatmap
debugtheworldbot 05b455f
fix(hourly): refine empty state presentation
debugtheworldbot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| import Foundation | ||
|
|
||
| /// 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, Equatable { | ||
| 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] = [:] | ||
| private(set) var recordedThrough: Date? | ||
|
|
||
| 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 = Self.hourInterval(containing: date, calendar: calendar) 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 availableThrough = min(now, recordedThrough ?? now) | ||
| let start: Date | ||
| let end: Date | ||
| if recent24Hours { | ||
| 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 [] } | ||
| start = day.start | ||
| end = day.end | ||
| } | ||
| var result: [Point] = [] | ||
| var hour = start | ||
| while hour < end { | ||
| 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 = interval.end | ||
| } | ||
| 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) | ||
| // 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() | ||
| } | ||
|
|
||
| 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 = 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 } | ||
| } | ||
| } | ||
|
|
||
| /// 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] = [:] | ||
| // 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) } | ||
| }) | ||
| shard.recordedThrough = max(startedAt, min(now, interval.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<Counts?>(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 } | ||
| 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 { | ||
| // 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 | ||
| // 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 | ||
| } | ||
|
|
||
| /// 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) | ||
| } | ||
|
|
||
| private func bucketKey(_ date: Date) -> String { | ||
| String(Int64(date.timeIntervalSince1970)) | ||
| } | ||
| } | ||
|
|
||
| enum HourlyStatsImportError: LocalizedError { | ||
| case differentTimeZones | ||
|
|
||
| var errorDescription: String? { | ||
| NSLocalizedString("hourly.import.differentTimeZones", comment: "") | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HourlyStats()capturesstartedAtas soon asStatsManageris instantiated, which occurs during menu-bar setup before the helper connects or accessibility permission is granted. Becausepoints()treats every absent bucket after this timestamp as a recorded zero, a first-launch user who delays permission—or any period when the helper cannot monitor—gets fabricated zero-activity hours instead of unavailable data. Begin coverage after monitoring starts successfully and preserve gaps when monitoring is inactive.Useful? React with 👍 / 👎.