Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions SimplyTrack.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions SimplyTrack/Models/UsageType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
66 changes: 60 additions & 6 deletions SimplyTrack/Services/TrackingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions SimplyTrack/Utils/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions SimplyTrack/Utils/UsageAggregator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsageSession>(
predicate: #Predicate<UsageSession> { 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] = [:]
Expand All @@ -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
}
}

Expand Down
46 changes: 44 additions & 2 deletions SimplyTrack/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -484,6 +485,13 @@ struct ContentView: View {
},
sortBy: [SortDescriptor(\.startTime)]
)

let weeklyIdleDescriptor = FetchDescriptor<UsageSession>(
predicate: #Predicate<UsageSession> { session in
session.startTime >= startOfWeek && session.startTime < endOfWeek && session.type == "idle" && session.endTime != nil
},
sortBy: [SortDescriptor(\.startTime)]
)

let iconDescriptor = FetchDescriptor<Icon>()
let icons = try modelContext.fetch(iconDescriptor)
Expand All @@ -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(
Expand Down Expand Up @@ -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)] = [:]

Expand Down
27 changes: 27 additions & 0 deletions SimplyTrack/Views/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 5 additions & 5 deletions SimplyTrack/Views/UsagePieChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down