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
4 changes: 4 additions & 0 deletions Dayflow/Dayflow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
67 changes: 65 additions & 2 deletions Dayflow/Dayflow/Core/Notifications/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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([])
}
Expand Down
56 changes: 56 additions & 0 deletions Dayflow/Dayflow/Core/Productivity/DistractionNudgeService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
61 changes: 61 additions & 0 deletions Dayflow/Dayflow/Core/Productivity/ProductivityPreferences.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
65 changes: 65 additions & 0 deletions Dayflow/Dayflow/Core/Productivity/ProductivityStats.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
53 changes: 53 additions & 0 deletions Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
21 changes: 21 additions & 0 deletions Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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())
Expand Down
Loading