diff --git a/Dayflow/Dayflow/App/AppDelegate.swift b/Dayflow/Dayflow/App/AppDelegate.swift index 460154c86..fe2623e7a 100644 --- a/Dayflow/Dayflow/App/AppDelegate.swift +++ b/Dayflow/Dayflow/App/AppDelegate.swift @@ -156,6 +156,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Start notification service for journal reminders NotificationService.shared.start() + // Start the productivity stats engine and the distraction nudge service. + ProductivityStats.shared.start() + DistractionNudgeService.shared.start() + // Start daily recap generation scheduler (checks every 5 minutes) DailyRecapScheduler.shared.start() diff --git a/Dayflow/Dayflow/Core/Notifications/NotificationService.swift b/Dayflow/Dayflow/Core/Notifications/NotificationService.swift index 83dc5f944..2aeae8eac 100644 --- a/Dayflow/Dayflow/Core/Notifications/NotificationService.swift +++ b/Dayflow/Dayflow/Core/Notifications/NotificationService.swift @@ -182,6 +182,54 @@ final class NotificationService: NSObject, ObservableObject { } } + /// Fire a gentle distraction nudge. Requests permission on demand (same flow + /// as the daily recap notification) so it works even before the user has + /// granted notifications elsewhere. + func sendDistractionNudge(distractionMinutes: Int) { + Task { + var settings = await center.notificationSettings() + var status = settings.authorizationStatus + + if status == .notDetermined { + _ = await requestPermission() + settings = await center.notificationSettings() + status = settings.authorizationStatus + } + + guard Self.canScheduleNotifications(for: status) else { + print( + "[NotificationService] Skipping distraction nudge: " + + "permission_status=\(Self.authorizationStatusName(status))" + ) + return + } + + let identifier = "nudge.distraction" + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + + let content = UNMutableNotificationContent() + content.title = "Time for a reset?" + content.body = + "About \(distractionMinutes) min of distraction in the last half hour. Want to refocus?" + content.sound = .default + content.categoryIdentifier = "distraction_nudge" + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + ) + + center.add(request) { error in + if let error { + print("[NotificationService] Failed to schedule distraction nudge: \(error)") + } else { + print("[NotificationService] Scheduled distraction nudge minutes=\(distractionMinutes)") + } + } + } + } + func scheduleWeeklyUnlockNotification(at unlockDate: Date) async -> WeeklyUnlockNotificationScheduleResult { @@ -423,14 +471,23 @@ extension NotificationService: UNUserNotificationCenterDelegate { let isJournalNotification = identifier.hasPrefix("journal.") let isDailyRecapNotification = identifier.hasPrefix("daily.") let isWeeklyUnlockNotification = identifier.hasPrefix("weekly.") + let isNudgeNotification = identifier.hasPrefix("nudge.") - guard isJournalNotification || isDailyRecapNotification || isWeeklyUnlockNotification else { + guard + isJournalNotification || isDailyRecapNotification || isWeeklyUnlockNotification + || isNudgeNotification + else { completionHandler() return } Task { @MainActor in - if isJournalNotification { + if isNudgeNotification { + AppDelegate.pendingNotificationNavigationDestination = .daily(day: nil) + activateAppForNotificationTap() + print( + "[NotificationService] didReceive distraction nudge handled identifier=\(identifier)") + } else if isJournalNotification { NotificationBadgeManager.shared.showJournalBadge() AppDelegate.pendingNotificationNavigationDestination = .journal activateAppForNotificationTap() @@ -502,6 +559,12 @@ extension NotificationService: UNUserNotificationCenterDelegate { return } + if identifier.hasPrefix("nudge.") { + print("[NotificationService] willPresent options=banner,sound identifier=\(identifier)") + completionHandler([.banner, .sound]) + return + } + print("[NotificationService] willPresent: unknown notification identifier, skipping") completionHandler([]) } diff --git a/Dayflow/Dayflow/Core/Productivity/DistractionNudgeService.swift b/Dayflow/Dayflow/Core/Productivity/DistractionNudgeService.swift new file mode 100644 index 000000000..d7aec041c --- /dev/null +++ b/Dayflow/Dayflow/Core/Productivity/DistractionNudgeService.swift @@ -0,0 +1,56 @@ +// +// DistractionNudgeService.swift +// Dayflow +// +// Gentle, non‑blocking distraction nudges. Watches ProductivityStats and fires +// a local notification when distraction in the trailing window crosses the +// threshold — respecting a cooldown, pause/idle state, and the user toggle. +// +// This is intentionally not rule‑based blocking: it relies on Dayflow's existing +// AI categorization rather than URL/app blocklists. +// + +import AppKit +import Combine +import Foundation + +@MainActor +final class DistractionNudgeService: ObservableObject { + static let shared = DistractionNudgeService() + + private var timer: Timer? + private var lastNudgeAt: Date? + + private init() {} + + func start() { + evaluate() + // Re‑evaluate every 5 minutes. ProductivityStats keeps its window fresh on + // its own minute timer, so reading its published value here is enough. + let timer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) { [weak self] _ in + Task { @MainActor in self?.evaluate() } + } + self.timer = timer + } + + func evaluate() { + guard ProductivityPreferences.distractionNudgesEnabled else { return } + // Don't nag while paused or after the user has gone idle. + guard AppState.shared.isRecording else { return } + guard !PauseManager.shared.isPaused else { return } + guard !InactivityMonitor.shared.pendingReset else { return } + + let distracted = ProductivityStats.shared.distractedSecondsInNudgeWindow + guard distracted >= ProductivityPreferences.nudgeThresholdSeconds else { return } + + if let last = lastNudgeAt, + Date().timeIntervalSince(last) < ProductivityPreferences.nudgeCooldownSeconds + { + return + } + + lastNudgeAt = Date() + let minutes = Int((distracted / 60).rounded()) + NotificationService.shared.sendDistractionNudge(distractionMinutes: minutes) + } +} diff --git a/Dayflow/Dayflow/Core/Productivity/ProductivityPreferences.swift b/Dayflow/Dayflow/Core/Productivity/ProductivityPreferences.swift new file mode 100644 index 000000000..08d9c7d28 --- /dev/null +++ b/Dayflow/Dayflow/Core/Productivity/ProductivityPreferences.swift @@ -0,0 +1,61 @@ +// +// ProductivityPreferences.swift +// Dayflow +// +// UserDefaults-backed settings for distraction nudges. +// + +import Foundation + +enum ProductivityPreferences { + private static let defaults = UserDefaults.standard + + // MARK: - Keys + private static let distractionNudgesEnabledKey = "distractionNudgesEnabled" + private static let nudgeThresholdMinutesKey = "nudgeThresholdMinutes" + private static let nudgeCooldownMinutesKey = "nudgeCooldownMinutes" + + // MARK: - Enable + + /// Gentle distraction nudges. Default on. + static var distractionNudgesEnabled: Bool { + get { + if defaults.object(forKey: distractionNudgesEnabledKey) == nil { return true } + return defaults.bool(forKey: distractionNudgesEnabledKey) + } + set { defaults.set(newValue, forKey: distractionNudgesEnabledKey) } + } + + // MARK: - Tuning (user-configurable in Settings) + + /// Choices shown in Settings for how much distraction triggers a nudge. + static let nudgeThresholdMinuteOptions = [5, 10, 15, 20, 30, 45, 60] + /// Choices shown in Settings for the minimum gap between nudges. + static let nudgeCooldownMinuteOptions = [15, 30, 60, 120, 240] + + /// Minutes of distraction (within the trailing window) before a nudge. Default 20. + static var nudgeThresholdMinutes: Int { + get { + if defaults.object(forKey: nudgeThresholdMinutesKey) == nil { return 20 } + return defaults.integer(forKey: nudgeThresholdMinutesKey) + } + set { defaults.set(newValue, forKey: nudgeThresholdMinutesKey) } + } + + /// Minimum minutes between two nudges. Default 60. + static var nudgeCooldownMinutes: Int { + get { + if defaults.object(forKey: nudgeCooldownMinutesKey) == nil { return 60 } + return defaults.integer(forKey: nudgeCooldownMinutesKey) + } + set { defaults.set(newValue, forKey: nudgeCooldownMinutesKey) } + } + + /// Distraction within the trailing window required to fire a nudge. + static var nudgeThresholdSeconds: Double { Double(nudgeThresholdMinutes * 60) } + /// Trailing window the nudge looks back over — at least 30 min, and never + /// shorter than the threshold (so a high threshold can still fire). + static var nudgeWindowSeconds: Double { max(30 * 60, nudgeThresholdSeconds) } + /// Minimum gap between two nudges so we never spam the user. + static var nudgeCooldownSeconds: Double { Double(nudgeCooldownMinutes * 60) } +} diff --git a/Dayflow/Dayflow/Core/Productivity/ProductivityStats.swift b/Dayflow/Dayflow/Core/Productivity/ProductivityStats.swift new file mode 100644 index 000000000..9c6342f1d --- /dev/null +++ b/Dayflow/Dayflow/Core/Productivity/ProductivityStats.swift @@ -0,0 +1,65 @@ +// +// ProductivityStats.swift +// Dayflow +// +// Tracks how much distraction has occurred in a recent trailing window, which +// drives the distraction nudges. Uses epoch start_ts/end_ts (ground truth), +// never the display "h:mm a" text. +// + +import Combine +import Foundation + +@MainActor +final class ProductivityStats: ObservableObject { + static let shared = ProductivityStats() + + /// Distraction seconds within the trailing nudge window. Drives nudges. + @Published private(set) var distractedSecondsInNudgeWindow: Double = 0 + + private var timer: Timer? + private var observer: NSObjectProtocol? + + private init() {} + + func start() { + recompute() + // Refresh the window once a minute; the nudge service also re-evaluates + // on its own cadence and reads this published value. + let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor in self?.recompute() } + } + self.timer = timer + + observer = NotificationCenter.default.addObserver( + forName: .timelineDataUpdated, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.recompute() } + } + } + + // MARK: - Recompute + + private func recompute() { + let now = Date() + let dayInfo = now.getDayInfoFor4AMBoundary() + let spans = StorageManager.shared.fetchTimelineActivity(forDay: dayInfo.dayString) + + let distractionKey = normalizedCategoryKey("Distraction") + let windowStart = now.addingTimeInterval(-ProductivityPreferences.nudgeWindowSeconds) + + distractedSecondsInNudgeWindow = spans.reduce(0) { acc, span in + let categoryKey = normalizedCategoryKey( + span.category.trimmingCharacters(in: .whitespacesAndNewlines)) + guard categoryKey == distractionKey else { return acc } + + let spanStart = Date(timeIntervalSince1970: TimeInterval(span.startTs)) + let spanEnd = Date(timeIntervalSince1970: TimeInterval(span.endTs)) + let overlapStart = max(spanStart, windowStart) + let overlapEnd = min(spanEnd, now) + return acc + max(0, overlapEnd.timeIntervalSince(overlapStart)) + } + } +} diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift index 63b708ece..07ac8deaf 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift @@ -2,6 +2,13 @@ import Foundation import GRDB import Sentry +/// Epoch-based activity span for a timeline card. +struct TimelineActivitySpan: Sendable { + let category: String + let startTs: Int + let endTs: Int +} + extension StorageManager { func saveTimelineCardShell(batchId: Int64, card: TimelineCardShell) -> Int64? { let encoder = JSONEncoder() @@ -389,6 +396,52 @@ extension StorageManager { }) ?? [] } + /// Lightweight epoch-based activity for a day, used by distraction nudges. + /// Reads `start_ts`/`end_ts` (ground truth) rather than the display + /// `start`/`end` text, which can be timezone-skewed for some cards. + func fetchTimelineActivity(forDay day: String) -> [TimelineActivitySpan] { + guard let dayDate = dateFormatter.date(from: day) else { return [] } + let calendar = Calendar.current + + var startComponents = calendar.dateComponents([.year, .month, .day], from: dayDate) + startComponents.hour = 4 + startComponents.minute = 0 + startComponents.second = 0 + guard let dayStart = calendar.date(from: startComponents) else { return [] } + + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayDate) else { return [] } + var endComponents = calendar.dateComponents([.year, .month, .day], from: nextDay) + endComponents.hour = 4 + endComponents.minute = 0 + endComponents.second = 0 + guard let dayEnd = calendar.date(from: endComponents) else { return [] } + + let startTs = Int(dayStart.timeIntervalSince1970) + let endTs = Int(dayEnd.timeIntervalSince1970) + + let spans: [TimelineActivitySpan]? = try? timedRead("fetchTimelineActivity(forDay:\(day))") { + db in + try Row.fetchAll( + db, + sql: """ + SELECT category, start_ts, end_ts FROM timeline_cards + WHERE start_ts >= ? AND start_ts < ? + AND is_deleted = 0 + ORDER BY end_ts ASC + """, arguments: [startTs, endTs] + ) + .compactMap { row -> TimelineActivitySpan? in + guard + let category: String = row["category"], + let start: Int = row["start_ts"], + let end: Int = row["end_ts"] + else { return nil } + return TimelineActivitySpan(category: category, startTs: start, endTs: end) + } + } + return spans ?? [] + } + func fetchTimelineCards(forDay day: String) -> [TimelineCard] { let decoder = JSONDecoder() diff --git a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift index a62e10953..0f88d933f 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift @@ -36,6 +36,24 @@ final class OtherSettingsViewModel: ObservableObject { TimelapsePreferences.saveAllTimelapsesToDisk = saveAllTimelapsesToDisk } } + @Published var distractionNudgesEnabled: Bool { + didSet { + guard distractionNudgesEnabled != oldValue else { return } + ProductivityPreferences.distractionNudgesEnabled = distractionNudgesEnabled + } + } + @Published var nudgeThresholdMinutes: Int { + didSet { + guard nudgeThresholdMinutes != oldValue else { return } + ProductivityPreferences.nudgeThresholdMinutes = nudgeThresholdMinutes + } + } + @Published var nudgeCooldownMinutes: Int { + didSet { + guard nudgeCooldownMinutes != oldValue else { return } + ProductivityPreferences.nudgeCooldownMinutes = nudgeCooldownMinutes + } + } @Published var outputLanguageOverride: String @Published var isOutputLanguageOverrideSaved: Bool = true @@ -57,6 +75,9 @@ final class OtherSettingsViewModel: ObservableObject { UserDefaults.standard.object(forKey: "showTimelineAppIcons") as? Bool ?? true showDailyGoalPopups = DayGoalPreferences.showDailyGoalPopups saveAllTimelapsesToDisk = TimelapsePreferences.saveAllTimelapsesToDisk + distractionNudgesEnabled = ProductivityPreferences.distractionNudgesEnabled + nudgeThresholdMinutes = ProductivityPreferences.nudgeThresholdMinutes + nudgeCooldownMinutes = ProductivityPreferences.nudgeCooldownMinutes outputLanguageOverride = LLMOutputLanguagePreferences.override exportStartDate = timelineDisplayDate(from: Date()) exportEndDate = timelineDisplayDate(from: Date()) diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift index a8ec1b370..4ce2ae32a 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift @@ -62,11 +62,40 @@ struct SettingsOtherTabView: View { SettingsRow( label: "Save all timelapses to disk", subtitle: - "New and reprocessed timeline cards will pre-generate timelapse videos and store them on disk instead of building them on demand. Uses more storage and background processing.", - showsDivider: false + "New and reprocessed timeline cards will pre-generate timelapse videos and store them on disk instead of building them on demand. Uses more storage and background processing." ) { SettingsToggle(isOn: $viewModel.saveAllTimelapsesToDisk) } + + SettingsRow( + label: "Distraction nudges", + subtitle: + "A gentle reminder when you've spent too long distracted. Never blocks anything.", + showsDivider: viewModel.distractionNudgesEnabled + ) { + SettingsToggle(isOn: $viewModel.distractionNudgesEnabled) + } + + if viewModel.distractionNudgesEnabled { + SettingsRow( + label: "Nudge after", + subtitle: "Distraction time that triggers a nudge." + ) { + MinuteMenuPicker( + selection: $viewModel.nudgeThresholdMinutes, + options: ProductivityPreferences.nudgeThresholdMinuteOptions) + } + + SettingsRow( + label: "Wait between nudges", + subtitle: "Minimum quiet time after a nudge before the next one.", + showsDivider: false + ) { + MinuteMenuPicker( + selection: $viewModel.nudgeCooldownMinutes, + options: ProductivityPreferences.nudgeCooldownMinuteOptions) + } + } } } } @@ -113,3 +142,44 @@ struct SettingsOtherTabView: View { } } } + +/// Compact dropdown for picking a minutes value, styled like the other +/// Settings menus. +private struct MinuteMenuPicker: View { + @Binding var selection: Int + let options: [Int] + + var body: some View { + Menu { + ForEach(options, id: \.self) { minutes in + Button(Self.label(minutes)) { selection = minutes } + } + } label: { + HStack(spacing: 5) { + Text(Self.label(selection)) + .font(.custom("Figtree", size: 13)) + .fontWeight(.semibold) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundColor(SettingsStyle.ink) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.black.opacity(0.05)) + ) + } + .menuStyle(BorderlessButtonMenuStyle()) + .menuIndicator(.hidden) + .fixedSize() + .pointingHandCursor() + } + + static func label(_ minutes: Int) -> String { + if minutes >= 60 && minutes % 60 == 0 { + return "\(minutes / 60) hr" + } + return "\(minutes) min" + } +}