From 2df2c4ce03775f19afe813906469ffddbba75f53 Mon Sep 17 00:00:00 2001 From: Harish Dhanraj Sugandhi Date: Tue, 14 Oct 2025 22:02:18 +0530 Subject: [PATCH] feat: add configurable idle time tracking and visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive idle time tracking functionality that allows users to monitor periods of inactivity alongside their active app and website usage. ## New Features ### Configurable Idle Timeout - Add idle timeout setting in Settings → General - Support timeout options: 1, 2, 5, 10, 15, 30 minutes - Default timeout: 5 minutes (maintains existing behavior) - Settings stored in UserDefaults with environment separation ### Idle Time Tracking - Detect system idle time using CGEventSource - Create idle sessions when user becomes inactive - Seamlessly transition between active and idle states - Store idle periods as UsageSession objects with type "idle" ### Enhanced Visualization - Include idle time in pie charts (gray color) - Display up to 6 categories instead of 5 to accommodate idle time - Show idle time alongside top apps in usage breakdown - Support both daily and weekly idle time aggregation ### Data Model Updates - Add `idle` case to UsageType enum - Update UsageAggregator to handle idle sessions - Enhance data fetching to include idle time in analytics - Maintain backward compatibility with existing data ## Technical Implementation ### Core Changes - **UsageType.swift**: Added idle usage type - **TrackingService.swift**: Idle detection logic and session management - **SettingsStore.swift**: Configurable idle timeout setting - **ContentView.swift**: Enhanced data aggregation including idle sessions - **UsagePieChart.swift**: Visual support for idle time display - **GeneralSettingsView.swift**: UI for idle timeout configuration - **UsageAggregator.swift**: Idle time support in AI summaries ### Benefits - Better productivity insights with complete time accounting - Configurable tracking based on user work patterns - Visual distinction between active usage and idle periods - Enhanced time awareness and usage analytics --- SimplyTrack.xcodeproj/project.pbxproj | 6 +- SimplyTrack/Models/UsageType.swift | 2 + SimplyTrack/Services/TrackingService.swift | 66 +++++++++++++++++-- SimplyTrack/Utils/SettingsStore.swift | 4 ++ SimplyTrack/Utils/UsageAggregator.swift | 22 +++++++ SimplyTrack/Views/ContentView.swift | 46 ++++++++++++- .../Views/Settings/GeneralSettingsView.swift | 27 ++++++++ SimplyTrack/Views/UsagePieChart.swift | 10 +-- 8 files changed, 168 insertions(+), 15 deletions(-) diff --git a/SimplyTrack.xcodeproj/project.pbxproj b/SimplyTrack.xcodeproj/project.pbxproj index f938dd2..4edc694 100644 --- a/SimplyTrack.xcodeproj/project.pbxproj +++ b/SimplyTrack.xcodeproj/project.pbxproj @@ -398,6 +398,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = SimplyTrackMCP/SimplyTrackMCP.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = WD9D8KHH6Y; @@ -418,6 +419,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = SimplyTrackMCP/SimplyTrackMCP.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = WD9D8KHH6Y; @@ -566,7 +568,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; AUTOMATION_APPLE_EVENTS = YES; CODE_SIGN_ENTITLEMENTS = SimplyTrack/SimplyTrack.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 0; @@ -612,7 +614,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; AUTOMATION_APPLE_EVENTS = YES; CODE_SIGN_ENTITLEMENTS = SimplyTrack/SimplyTrack.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 0; diff --git a/SimplyTrack/Models/UsageType.swift b/SimplyTrack/Models/UsageType.swift index dde8418..f7adb56 100644 --- a/SimplyTrack/Models/UsageType.swift +++ b/SimplyTrack/Models/UsageType.swift @@ -14,4 +14,6 @@ enum UsageType: String, Codable, CaseIterable { case app /// Website usage tracking (browser-based activity) case website + /// Idle time tracking (periods of user inactivity) + case idle } diff --git a/SimplyTrack/Services/TrackingService.swift b/SimplyTrack/Services/TrackingService.swift index 6141ce8..a77a811 100644 --- a/SimplyTrack/Services/TrackingService.swift +++ b/SimplyTrack/Services/TrackingService.swift @@ -9,6 +9,7 @@ import AppKit import CoreGraphics import Foundation import SwiftData +import SwiftUI import os /// Core service responsible for tracking app and website usage patterns. @@ -37,11 +38,17 @@ class TrackingService { private var currentAppSession: UsageSession? private var currentWebsiteSession: UsageSession? + private var currentIdleSession: UsageSession? // MARK: - Idle Detection private var lastActivityTime = Date() - private let idleThreshold: TimeInterval = 300 + private var wasIdleLastUpdate = false + @AppStorage("idleTimeoutSeconds", store: .app) private var idleTimeoutSeconds: Double = AppStorageDefaults.idleTimeoutSeconds + + private var idleThreshold: TimeInterval { + return idleTimeoutSeconds + } /// Initializes the tracking service with required dependencies. /// - Parameters: @@ -101,10 +108,21 @@ class TrackingService { private func updateCurrentActivity() async { let now = Date() let systemIdleTime = getSystemIdleTime() - - // Check if user has been idle for more than threshold - if systemIdleTime >= idleThreshold { - await endAllActiveSessions() + let isCurrentlyIdle = systemIdleTime >= idleThreshold + + // Handle idle state transitions + if isCurrentlyIdle && !wasIdleLastUpdate { + // Just became idle - end active sessions and start idle session + await endActiveNonIdleSessions() + startIdleSession(at: now.addingTimeInterval(-systemIdleTime)) + wasIdleLastUpdate = true + return + } else if !isCurrentlyIdle && wasIdleLastUpdate { + // Just became active - end idle session + endIdleSession(at: now) + wasIdleLastUpdate = false + } else if isCurrentlyIdle { + // Still idle - continue idle session, no action needed return } @@ -197,8 +215,37 @@ class TrackingService { sessionPersistenceService.queueSession(websiteSession) currentWebsiteSession = nil } + + private func startIdleSession(at startTime: Date) { + currentIdleSession = UsageSession(type: .idle, identifier: "idle", name: "Idle", startTime: startTime) + } + + private func endIdleSession(at endTime: Date) { + guard let idleSession = currentIdleSession else { return } + idleSession.endSession(at: endTime) + sessionPersistenceService.queueSession(idleSession) + currentIdleSession = nil + } + + private func endActiveNonIdleSessions() async { + let now = Date() + + // End app session if active + if let appSession = currentAppSession { + appSession.endSession(at: now) + sessionPersistenceService.queueSession(appSession) + currentAppSession = nil + } + + // End website session if active + if let websiteSession = currentWebsiteSession { + websiteSession.endSession(at: now) + sessionPersistenceService.queueSession(websiteSession) + currentWebsiteSession = nil + } + } - /// Ends all currently active sessions (both app and website). + /// Ends all currently active sessions (app, website, and idle). /// Called when user goes idle or system enters inactive state. /// Public method that can be called by AppDelegate during app termination. func endAllActiveSessions() async { @@ -217,6 +264,13 @@ class TrackingService { sessionPersistenceService.queueSession(websiteSession) currentWebsiteSession = nil } + + // End idle session if active + if let idleSession = currentIdleSession { + idleSession.endSession(at: now) + sessionPersistenceService.queueSession(idleSession) + currentIdleSession = nil + } } // MARK: - Idle Detection diff --git a/SimplyTrack/Utils/SettingsStore.swift b/SimplyTrack/Utils/SettingsStore.swift index a367f09..4f9507e 100644 --- a/SimplyTrack/Utils/SettingsStore.swift +++ b/SimplyTrack/Utils/SettingsStore.swift @@ -33,6 +33,10 @@ struct AppStorageDefaults { /// Default private browsing tracking preference. /// Privacy-first approach: disabled by default to protect user privacy. static let trackPrivateBrowsing = false + + /// Default idle timeout threshold in seconds (5 minutes). + /// Time of inactivity before tracking is paused. + static let idleTimeoutSeconds: Double = 300 } /// UserDefaults extension providing environment-specific storage isolation. diff --git a/SimplyTrack/Utils/UsageAggregator.swift b/SimplyTrack/Utils/UsageAggregator.swift index 0f07d7f..bebfa5d 100644 --- a/SimplyTrack/Utils/UsageAggregator.swift +++ b/SimplyTrack/Utils/UsageAggregator.swift @@ -35,6 +35,26 @@ struct UsageAggregator { let sessions = try modelContext.fetch(descriptor) return aggregateAndFormat(sessions: sessions, topPercentage: topPercentage) } + + /// Aggregates idle time for a given date + /// - Parameters: + /// - date: The date to aggregate idle time for + /// - modelContext: SwiftData model context for database access + /// - Returns: Total idle time for the date + static func aggregateIdleTime(for date: Date, modelContext: ModelContext) throws -> TimeInterval { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + let descriptor = FetchDescriptor( + predicate: #Predicate { session in + session.startTime >= startOfDay && session.startTime < endOfDay && session.endTime != nil && session.type == "idle" + } + ) + + let sessions = try modelContext.fetch(descriptor) + return sessions.reduce(0) { $0 + $1.duration } + } private static func aggregateAndFormat(sessions: [UsageSession], topPercentage: Double) -> String { var appUsage: [String: TimeInterval] = [:] @@ -48,6 +68,8 @@ struct UsageAggregator { appUsage[session.name] = (appUsage[session.name] ?? 0) + duration } else if session.type == UsageType.website.rawValue { websiteUsage[session.name] = (websiteUsage[session.name] ?? 0) + duration + } else if session.type == UsageType.idle.rawValue { + appUsage["Idle"] = (appUsage["Idle"] ?? 0) + duration } } diff --git a/SimplyTrack/Views/ContentView.swift b/SimplyTrack/Views/ContentView.swift index 1be01ff..ce914a3 100644 --- a/SimplyTrack/Views/ContentView.swift +++ b/SimplyTrack/Views/ContentView.swift @@ -441,10 +441,11 @@ struct ContentView: View { let dailyResult = try modelContext.fetch(dailyDescriptor) let appSessions = dailyResult.filter { $0.type == "app" } let websiteSessions = dailyResult.filter { $0.type == "website" } + let idleSessions = dailyResult.filter { $0.type == "idle" } let workPeriods = computeWorkPeriods(from: appSessions) let totalActiveTime = appSessions.reduce(0) { $0 + $1.duration } - let topApps = aggregateAppSessions(appSessions, iconMap: iconMap) + let topApps = aggregateAllSessions(appSessions: appSessions, idleSessions: idleSessions, iconMap: iconMap) let topWebsites = aggregateWebsiteSessions(websiteSessions, iconMap: iconMap) return DailyDataResults( @@ -484,6 +485,13 @@ struct ContentView: View { }, sortBy: [SortDescriptor(\.startTime)] ) + + let weeklyIdleDescriptor = FetchDescriptor( + predicate: #Predicate { session in + session.startTime >= startOfWeek && session.startTime < endOfWeek && session.type == "idle" && session.endTime != nil + }, + sortBy: [SortDescriptor(\.startTime)] + ) let iconDescriptor = FetchDescriptor() let icons = try modelContext.fetch(iconDescriptor) @@ -495,10 +503,11 @@ struct ContentView: View { let weeklyResult = try modelContext.fetch(weeklyDescriptor) let weeklyWebsiteResult = try modelContext.fetch(weeklyWebsiteDescriptor) + let weeklyIdleResult = try modelContext.fetch(weeklyIdleDescriptor) let weeklyActivity = computeWeeklyActivity(from: weeklyResult) let weeklyTotalActiveTime = weeklyResult.reduce(0) { $0 + $1.duration } - let weeklyTopApps = aggregateAppSessions(weeklyResult, iconMap: iconMap) + let weeklyTopApps = aggregateAllSessions(appSessions: weeklyResult, idleSessions: weeklyIdleResult, iconMap: iconMap) let weeklyTopWebsites = aggregateWebsiteSessions(weeklyWebsiteResult, iconMap: iconMap) return WeeklyDataResults( @@ -559,6 +568,39 @@ struct ContentView: View { return result } + private func aggregateAllSessions(appSessions: [UsageSession], idleSessions: [UsageSession], iconMap: [String: Data]) -> [(identifier: String, name: String, iconData: Data?, totalTime: TimeInterval)] { + var allData: [String: (name: String, totalTime: TimeInterval)] = [:] + + // Add app sessions + for session in appSessions { + let existing = allData[session.identifier, default: (name: session.name, totalTime: 0)] + allData[session.identifier] = ( + name: existing.name, + totalTime: existing.totalTime + session.duration + ) + } + + // Add idle sessions + var totalIdleTime: TimeInterval = 0 + for session in idleSessions { + totalIdleTime += session.duration + } + + if totalIdleTime > 0 { + allData["idle"] = (name: "Idle", totalTime: totalIdleTime) + } + + let result = + allData + .map { (identifier, data) in + let iconData = identifier == "idle" ? nil : iconMap[identifier] + return (identifier: identifier, name: data.name, iconData: iconData, totalTime: data.totalTime) + } + .sorted { $0.totalTime > $1.totalTime } + + return result + } + private func aggregateAppSessions(_ sessions: [UsageSession], iconMap: [String: Data]) -> [(identifier: String, name: String, iconData: Data?, totalTime: TimeInterval)] { var appData: [String: (name: String, totalTime: TimeInterval)] = [:] diff --git a/SimplyTrack/Views/Settings/GeneralSettingsView.swift b/SimplyTrack/Views/Settings/GeneralSettingsView.swift index 45405e5..fcfba5c 100644 --- a/SimplyTrack/Views/Settings/GeneralSettingsView.swift +++ b/SimplyTrack/Views/Settings/GeneralSettingsView.swift @@ -14,6 +14,7 @@ struct GeneralSettingsView: View { @StateObject private var loginItemManager = LoginItemManager.shared @State private var launchAtLoginEnabled = false @AppStorage("updateFrequency", store: .app) private var updateFrequency: UpdateFrequency = .daily + @AppStorage("idleTimeoutSeconds", store: .app) private var idleTimeoutSeconds: Double = AppStorageDefaults.idleTimeoutSeconds var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -61,6 +62,32 @@ struct GeneralSettingsView: View { .font(.caption) .foregroundColor(.secondary) } + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Image(systemName: "clock") + .foregroundColor(.orange) + .frame(width: 16) + Text("Idle timeout") + + Spacer() + + Picker("", selection: $idleTimeoutSeconds) { + Text("1 minute").tag(60.0) + Text("2 minutes").tag(120.0) + Text("5 minutes").tag(300.0) + Text("10 minutes").tag(600.0) + Text("15 minutes").tag(900.0) + Text("30 minutes").tag(1800.0) + } + .pickerStyle(.menu) + .frame(width: 200) + } + + Text("Stop tracking after this period of inactivity") + .font(.caption) + .foregroundColor(.secondary) + } } .formStyle(.grouped) diff --git a/SimplyTrack/Views/UsagePieChart.swift b/SimplyTrack/Views/UsagePieChart.swift index a702f7e..fd218f5 100644 --- a/SimplyTrack/Views/UsagePieChart.swift +++ b/SimplyTrack/Views/UsagePieChart.swift @@ -8,19 +8,19 @@ import Charts import SwiftUI -/// Displays a pie chart showing top application usage with legend. -/// Shows the top 5 applications by usage time with colored segments and labels. +/// Displays a pie chart showing top usage categories including apps and idle time. +/// Shows the top 5 usage categories by time with colored segments and labels. struct UsagePieChart: View { /// Selected date for animation key let selectedDate: Date - /// Top applications data for pie chart display + /// Top usage categories data for pie chart display (apps, idle, etc.) let topApps: [(identifier: String, name: String, iconData: Data?, totalTime: TimeInterval)] private var topFiveApps: [(identifier: String, name: String, iconData: Data?, totalTime: TimeInterval)] { - Array(topApps.prefix(5)) + Array(topApps.prefix(6)) // Increased to 6 to accommodate idle time } - private let colors: [Color] = [.blue, .green, .orange, .red, .purple] + private let colors: [Color] = [.blue, .green, .orange, .red, .purple, .gray] var body: some View { HStack(spacing: 12) {