From 24ad1148888c91c99d6c1170a72605b599ec76b3 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 30 Mar 2026 15:57:35 +0200 Subject: [PATCH 1/3] feat(active-times): add date to active time range for cross-midnight sessions Store firstActiveAt/lastActiveAt as yyyy-MM-dd'T'HH:mm instead of HH:mm. Format the range smartly: same-day shows HH:mm - HH:mm, cross-day shows MMM d, HH:mm - MMM d, HH:mm. Falls back gracefully for legacy HH:mm values. Closes #243 Co-Authored-By: Claude Sonnet 4.6 --- .../InputMetrics/Services/EventMonitor.swift | 2 +- .../ViewModels/MenuBarViewModel.swift | 35 +++++++++++++++++++ .../InputMetrics/Views/MenuBarView.swift | 5 ++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/InputMetrics/InputMetrics/Services/EventMonitor.swift b/InputMetrics/InputMetrics/Services/EventMonitor.swift index 24bc832..07d856a 100644 --- a/InputMetrics/InputMetrics/Services/EventMonitor.swift +++ b/InputMetrics/InputMetrics/Services/EventMonitor.swift @@ -35,7 +35,7 @@ class EventMonitor { private static let timeFormatter: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "HH:mm" + f.dateFormat = "yyyy-MM-dd'T'HH:mm" return f }() diff --git a/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift b/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift index a57981e..f4c7ea9 100644 --- a/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift +++ b/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift @@ -42,6 +42,41 @@ final class MenuBarViewModel { leftClicks + rightClicks + middleClicks } + var formattedActiveTimeRange: String? { + guard let first = firstActiveAt, let last = lastActiveAt else { return nil } + + let isoFormatter = DateFormatter() + isoFormatter.locale = Locale(identifier: "en_US_POSIX") + isoFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm" + + let legacyFormatter = DateFormatter() + legacyFormatter.locale = Locale(identifier: "en_US_POSIX") + legacyFormatter.dateFormat = "HH:mm" + + let timeFormatter = DateFormatter() + timeFormatter.locale = Locale(identifier: "en_US_POSIX") + timeFormatter.dateFormat = "HH:mm" + + let dateTimeFormatter = DateFormatter() + dateTimeFormatter.locale = Locale(identifier: "en_US_POSIX") + dateTimeFormatter.dateFormat = "MMM d, HH:mm" + + guard let firstDate = isoFormatter.date(from: first) ?? legacyFormatter.date(from: first), + let lastDate = isoFormatter.date(from: last) ?? legacyFormatter.date(from: last) else { + return "\(first) - \(last)" + } + + let calendar = Calendar.current + let firstDay = calendar.startOfDay(for: firstDate) + let lastDay = calendar.startOfDay(for: lastDate) + + if firstDay == lastDay { + return "\(timeFormatter.string(from: firstDate)) - \(timeFormatter.string(from: lastDate))" + } else { + return "\(dateTimeFormatter.string(from: firstDate)) - \(dateTimeFormatter.string(from: lastDate))" + } + } + var topKeys: [KeyboardEntry] { Array(keyboardEntries.sorted { $0.count > $1.count }.prefix(5)) } diff --git a/InputMetrics/InputMetrics/Views/MenuBarView.swift b/InputMetrics/InputMetrics/Views/MenuBarView.swift index ea2feab..1bc37a5 100644 --- a/InputMetrics/InputMetrics/Views/MenuBarView.swift +++ b/InputMetrics/InputMetrics/Views/MenuBarView.swift @@ -97,9 +97,8 @@ struct MenuBarView: View { .font(.title3) .foregroundStyle(.teal) - if let first = viewModel.firstActiveAt, - let last = viewModel.lastActiveAt { - Text("\(first) - \(last)") + if let range = viewModel.formattedActiveTimeRange { + Text(range) .font(.headline.monospacedDigit()) } else { Text("Activity tracking will begin as you use your Mac") From eb1c2c3f65d5100ebd3ea6d688944cac191c254a Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 30 Mar 2026 16:01:18 +0200 Subject: [PATCH 2/3] fix(active-times): treat legacy HH:mm values as same-day to avoid Jan 1 epoch date --- .../InputMetrics/ViewModels/MenuBarViewModel.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift b/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift index f4c7ea9..27d9ae5 100644 --- a/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift +++ b/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift @@ -61,9 +61,16 @@ final class MenuBarViewModel { dateTimeFormatter.locale = Locale(identifier: "en_US_POSIX") dateTimeFormatter.dateFormat = "MMM d, HH:mm" - guard let firstDate = isoFormatter.date(from: first) ?? legacyFormatter.date(from: first), - let lastDate = isoFormatter.date(from: last) ?? legacyFormatter.date(from: last) else { - return "\(first) - \(last)" + let isFirstISO = isoFormatter.date(from: first) != nil + let isLastISO = isoFormatter.date(from: last) != nil + + guard isFirstISO && isLastISO, + let firstDate = isoFormatter.date(from: first), + let lastDate = isoFormatter.date(from: last) else { + // Legacy HH:mm values — no date info, just display as-is + let firstDisplay = legacyFormatter.date(from: first).map { timeFormatter.string(from: $0) } ?? first + let lastDisplay = legacyFormatter.date(from: last).map { timeFormatter.string(from: $0) } ?? last + return "\(firstDisplay) - \(lastDisplay)" } let calendar = Calendar.current From 9bf175e29133d10d1b503e09376e84b90a4f979f Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 30 Mar 2026 16:02:48 +0200 Subject: [PATCH 3/3] fix(active-times): handle mixed legacy/ISO format in active time range display --- .../ViewModels/MenuBarViewModel.swift | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift b/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift index 27d9ae5..98f2e08 100644 --- a/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift +++ b/InputMetrics/InputMetrics/ViewModels/MenuBarViewModel.swift @@ -61,27 +61,26 @@ final class MenuBarViewModel { dateTimeFormatter.locale = Locale(identifier: "en_US_POSIX") dateTimeFormatter.dateFormat = "MMM d, HH:mm" - let isFirstISO = isoFormatter.date(from: first) != nil - let isLastISO = isoFormatter.date(from: last) != nil - - guard isFirstISO && isLastISO, - let firstDate = isoFormatter.date(from: first), - let lastDate = isoFormatter.date(from: last) else { - // Legacy HH:mm values — no date info, just display as-is - let firstDisplay = legacyFormatter.date(from: first).map { timeFormatter.string(from: $0) } ?? first - let lastDisplay = legacyFormatter.date(from: last).map { timeFormatter.string(from: $0) } ?? last - return "\(firstDisplay) - \(lastDisplay)" + func parse(_ value: String) -> Date? { + isoFormatter.date(from: value) ?? legacyFormatter.date(from: value) } - let calendar = Calendar.current - let firstDay = calendar.startOfDay(for: firstDate) - let lastDay = calendar.startOfDay(for: lastDate) + guard let firstDate = parse(first), let lastDate = parse(last) else { + return "\(first) - \(last)" + } - if firstDay == lastDay { - return "\(timeFormatter.string(from: firstDate)) - \(timeFormatter.string(from: lastDate))" - } else { - return "\(dateTimeFormatter.string(from: firstDate)) - \(dateTimeFormatter.string(from: lastDate))" + let firstIsISO = isoFormatter.date(from: first) != nil + let lastIsISO = isoFormatter.date(from: last) != nil + + // Only show dates if both values carry date info and they span different days + if firstIsISO && lastIsISO { + let calendar = Calendar.current + if calendar.startOfDay(for: firstDate) != calendar.startOfDay(for: lastDate) { + return "\(dateTimeFormatter.string(from: firstDate)) - \(dateTimeFormatter.string(from: lastDate))" + } } + + return "\(timeFormatter.string(from: firstDate)) - \(timeFormatter.string(from: lastDate))" } var topKeys: [KeyboardEntry] {