diff --git a/AGENTS.md b/AGENTS.md index 98bbf70..66bc79e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ Most services are `@Observable @MainActor` classes passed through the SwiftUI en | `HomeService` | `Services/Home/` | Home / Room CRUD, primary-home promotion | | `DeviceService` | `Services/Device/` | PlannedDevice CRUD; routes `SceneActionSnapshot` to the right bridge | | `DeviceStateStore` | `Services/Device/` | In-memory live state for all connected devices β€” rebuilt from bridges on each launch, never persisted | -| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered automation. Owns a cancellable `monitoringTask` for the geofence poller | +| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered + daily-schedule automation. Owns cancellable `monitoringTask` (geofence poller) and `scheduleTask` (schedule poller) | | `LocationService` | `Services/` | CLLocationManager wrapper; publishes `GeofenceEvent` when the user crosses the home radius. Gates first-check event emission via `hasCompletedFirstCheck` so launching at home does not fire a spurious arrival | | `NotificationService` | `Services/` | UNUserNotificationCenter wrapper; called by `SceneService` after automation fires | | `SensorObservationService` | `Services/Intelligence/` | Subscribes to motion/contact `AsyncStream`s from all capable devices. Wired in `RootView` via `DeviceStateStore.onDevicesDiscovered/onDevicesRemoved` | @@ -131,6 +131,7 @@ A convention: when view code carries non-trivial logic, lift it into a pure `str | `ReasoningCalculator` | `LumenReasoningView` | Signal list + suggestion label from ambient state (incl. confidence / habit signals from the scored layer) | | `SuggestionEngine` | `HomeDashboardView` | Scored-heuristic ranking of scenes β†’ the "Lumen noticed" suggestion + explainable factors (see below) | | `SceneService.scenesMatching(event:in:)` | `SceneService.handleGeofenceEvent` | Pure routing β€” which scenes fire for a given event | +| `ScheduleTiming` / `SceneService.scenesDue(at:in:lastFired:)` | `SceneService` schedule poller | Pure: whether a daily-schedule scene is due now (grace window, no same-day refire, midnight-safe) | Follow this pattern for new feature work. @@ -214,6 +215,10 @@ The flag defaults to `true`. Tests rely on it indirectly: `DeviceService.addPlan - During tests, `RootView` skips registering the HomeKit bridge (`if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil`). The poller still starts but has nothing to react to. - Arrival and departure events trigger a status overlay in `HomeDashboardView` (e.g., β€œπŸ  Welcome Home!” or β€œπŸŒ™ Away Mode”) to inform the user of the detected state change. +#### Schedule automation + +A scene can carry a daily schedule (`Scene.scheduleMinutesSinceMidnight: Int?`, nullable β†’ inferred migration, no schema bump). `SceneService.startMonitoringScheduledScenes()` runs a `scheduleTask` poller (30 s tick) that fires due scenes via the same `execute(_:)` path and posts a notification. **Scheduled scenes fire directly** β€” the user set the time ahead of time, which is the consent β€” a deliberate decision logged in `ROADMAP.md` (distinct from the forbidden auto-apply-on-a-hunch; inferred `SuggestionEngine` suggestions stay confirmation-gated). Purity lives in `ScheduleTiming` (grace window so a missed schedule doesn't fire stale; no same-day refire) and `SceneService.scenesDue(...)`. Authored in `SceneDetailView`'s automation section (toggle + `DatePicker`), persisted via `SceneService.setSchedule(...)`. The poller starts in `RootView.bootstrap` next to the geofence poller. + ### Tests The `LumenTests` target uses XCTest with an in-memory `ModelContainer` via `PersistenceCoordinator.makeInMemoryContainer()`. Tests are `@MainActor` where they touch services or view models. @@ -236,6 +241,7 @@ Coverage groups (~195 tests at time of writing): | `SceneApprovalTests` | Approval flow (request/cancel/confirm), `SceneActionDescription` humanization | | `SceneActionBuilderTests` | Eligible-device filtering (read-only exclusion), sorted controllable-capability options, default payload per capability | | `GeofenceRoutingTests` | `scenesMatching` routes events to correctly-triggered scenes | +| `ScheduleTests` | `ScheduleTiming` due-logic (grace window, no same-day refire, next-day, midnight) + `SceneService.scenesDue` routing | | `RhythmTests` | `RhythmTiming` block math, midnight wrap | | `ReasoningTests` | `ReasoningCalculator` signal generation, suggestion labels, confidence/habit signals from the scored layer | | `SuggestionEngineTests` | `SuggestionEngine` scoring, surface threshold, Β±1h midnight-wrap habit window, presence/geofence boost, deterministic ranking, explainable factors | diff --git a/CLAUDE.md b/CLAUDE.md index 98bbf70..66bc79e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Most services are `@Observable @MainActor` classes passed through the SwiftUI en | `HomeService` | `Services/Home/` | Home / Room CRUD, primary-home promotion | | `DeviceService` | `Services/Device/` | PlannedDevice CRUD; routes `SceneActionSnapshot` to the right bridge | | `DeviceStateStore` | `Services/Device/` | In-memory live state for all connected devices β€” rebuilt from bridges on each launch, never persisted | -| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered automation. Owns a cancellable `monitoringTask` for the geofence poller | +| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered + daily-schedule automation. Owns cancellable `monitoringTask` (geofence poller) and `scheduleTask` (schedule poller) | | `LocationService` | `Services/` | CLLocationManager wrapper; publishes `GeofenceEvent` when the user crosses the home radius. Gates first-check event emission via `hasCompletedFirstCheck` so launching at home does not fire a spurious arrival | | `NotificationService` | `Services/` | UNUserNotificationCenter wrapper; called by `SceneService` after automation fires | | `SensorObservationService` | `Services/Intelligence/` | Subscribes to motion/contact `AsyncStream`s from all capable devices. Wired in `RootView` via `DeviceStateStore.onDevicesDiscovered/onDevicesRemoved` | @@ -131,6 +131,7 @@ A convention: when view code carries non-trivial logic, lift it into a pure `str | `ReasoningCalculator` | `LumenReasoningView` | Signal list + suggestion label from ambient state (incl. confidence / habit signals from the scored layer) | | `SuggestionEngine` | `HomeDashboardView` | Scored-heuristic ranking of scenes β†’ the "Lumen noticed" suggestion + explainable factors (see below) | | `SceneService.scenesMatching(event:in:)` | `SceneService.handleGeofenceEvent` | Pure routing β€” which scenes fire for a given event | +| `ScheduleTiming` / `SceneService.scenesDue(at:in:lastFired:)` | `SceneService` schedule poller | Pure: whether a daily-schedule scene is due now (grace window, no same-day refire, midnight-safe) | Follow this pattern for new feature work. @@ -214,6 +215,10 @@ The flag defaults to `true`. Tests rely on it indirectly: `DeviceService.addPlan - During tests, `RootView` skips registering the HomeKit bridge (`if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil`). The poller still starts but has nothing to react to. - Arrival and departure events trigger a status overlay in `HomeDashboardView` (e.g., β€œπŸ  Welcome Home!” or β€œπŸŒ™ Away Mode”) to inform the user of the detected state change. +#### Schedule automation + +A scene can carry a daily schedule (`Scene.scheduleMinutesSinceMidnight: Int?`, nullable β†’ inferred migration, no schema bump). `SceneService.startMonitoringScheduledScenes()` runs a `scheduleTask` poller (30 s tick) that fires due scenes via the same `execute(_:)` path and posts a notification. **Scheduled scenes fire directly** β€” the user set the time ahead of time, which is the consent β€” a deliberate decision logged in `ROADMAP.md` (distinct from the forbidden auto-apply-on-a-hunch; inferred `SuggestionEngine` suggestions stay confirmation-gated). Purity lives in `ScheduleTiming` (grace window so a missed schedule doesn't fire stale; no same-day refire) and `SceneService.scenesDue(...)`. Authored in `SceneDetailView`'s automation section (toggle + `DatePicker`), persisted via `SceneService.setSchedule(...)`. The poller starts in `RootView.bootstrap` next to the geofence poller. + ### Tests The `LumenTests` target uses XCTest with an in-memory `ModelContainer` via `PersistenceCoordinator.makeInMemoryContainer()`. Tests are `@MainActor` where they touch services or view models. @@ -236,6 +241,7 @@ Coverage groups (~195 tests at time of writing): | `SceneApprovalTests` | Approval flow (request/cancel/confirm), `SceneActionDescription` humanization | | `SceneActionBuilderTests` | Eligible-device filtering (read-only exclusion), sorted controllable-capability options, default payload per capability | | `GeofenceRoutingTests` | `scenesMatching` routes events to correctly-triggered scenes | +| `ScheduleTests` | `ScheduleTiming` due-logic (grace window, no same-day refire, next-day, midnight) + `SceneService.scenesDue` routing | | `RhythmTests` | `RhythmTiming` block math, midnight wrap | | `ReasoningTests` | `ReasoningCalculator` signal generation, suggestion labels, confidence/habit signals from the scored layer | | `SuggestionEngineTests` | `SuggestionEngine` scoring, surface threshold, Β±1h midnight-wrap habit window, presence/geofence boost, deterministic ranking, explainable factors | diff --git a/Lumen/Domain/Models/Automation/Scene.swift b/Lumen/Domain/Models/Automation/Scene.swift index 353082c..33464f6 100644 --- a/Lumen/Domain/Models/Automation/Scene.swift +++ b/Lumen/Domain/Models/Automation/Scene.swift @@ -27,6 +27,10 @@ final class Scene { var sortOrder: Int var isFavorite: Bool var geofenceTrigger: GeofenceTrigger + // Daily schedule as minutes since local midnight (0…1439). `nil` means the + // scene has no schedule. Optional, so SwiftData infers a lightweight + // nullable-column migration (no schema-version bump), like Home.latitude. + var scheduleMinutesSinceMidnight: Int? var createdAt: Date var updatedAt: Date @@ -38,19 +42,27 @@ final class Scene { name: String, iconName: String = "sparkles", sortOrder: Int = 0, - geofenceTrigger: GeofenceTrigger = .none + geofenceTrigger: GeofenceTrigger = .none, + scheduleMinutesSinceMidnight: Int? = nil ) { self.id = id self.name = name self.iconName = iconName self.sortOrder = sortOrder self.geofenceTrigger = geofenceTrigger + self.scheduleMinutesSinceMidnight = scheduleMinutesSinceMidnight self.isFavorite = false self.createdAt = Date() self.updatedAt = Date() self.actions = [] } + /// The scene's schedule as (hour, minute), or nil when unscheduled. + var scheduledTime: (hour: Int, minute: Int)? { + guard let minutes = scheduleMinutesSinceMidnight, (0..<1440).contains(minutes) else { return nil } + return (minutes / 60, minutes % 60) + } + func asSnapshots() -> [SceneActionSnapshot] { actions .sorted { $0.sortOrder < $1.sortOrder } diff --git a/Lumen/Features/RootView.swift b/Lumen/Features/RootView.swift index b8c714a..00587f2 100644 --- a/Lumen/Features/RootView.swift +++ b/Lumen/Features/RootView.swift @@ -155,6 +155,9 @@ struct RootView: View { // Start geofence event monitoring sceneService.startMonitoringGeofenceEvents(from: locationService) + + // Start daily-schedule monitoring (scheduled scenes fire directly) + sceneService.startMonitoringScheduledScenes() if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { let hkBridge = HomeKitBridge() diff --git a/Lumen/Features/Scenes/SceneDetailView.swift b/Lumen/Features/Scenes/SceneDetailView.swift index 2232a02..2e71b91 100644 --- a/Lumen/Features/Scenes/SceneDetailView.swift +++ b/Lumen/Features/Scenes/SceneDetailView.swift @@ -13,6 +13,10 @@ struct SceneDetailView: View { @State private var isShowingAddAction = false @State private var editName: String = "" @State private var selectedGeofenceTrigger: GeofenceTrigger = .none + @State private var isScheduled: Bool = false + @State private var scheduleTime: Date = Calendar.current.date( + bySettingHour: 21, minute: 0, second: 0, of: Date() + ) ?? Date() var body: some View { ZStack { @@ -32,6 +36,14 @@ struct SceneDetailView: View { .onAppear { editName = scene.name selectedGeofenceTrigger = scene.geofenceTrigger + if let (hour, minute) = scene.scheduledTime { + isScheduled = true + scheduleTime = Calendar.current.date( + bySettingHour: hour, minute: minute, second: 0, of: Date() + ) ?? scheduleTime + } else { + isScheduled = false + } } .sheet(isPresented: $isShowingAddAction) { AddSceneActionSheet( @@ -131,7 +143,54 @@ struct SceneDetailView: View { } } .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 16)) + + scheduleCard + } + } + + private var scheduleCard: some View { + VStack(spacing: 0) { + Toggle(isOn: $isScheduled) { + Label("Run on a schedule", systemImage: "clock.fill") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + } + .tint(Color(hex: "#C49A6C")) + .onChange(of: isScheduled) { _, enabled in + // Ignore the toggle flip that comes from onAppear syncing to the model. + guard enabled != (scene.scheduleMinutesSinceMidnight != nil) else { return } + viewModel.setSchedule( + minutesSinceMidnight: enabled ? Self.minutes(from: scheduleTime) : nil, + on: scene + ) + } + .padding(16) + + if isScheduled { + Divider().overlay(Color.white.opacity(0.08)) + DatePicker( + "Time", + selection: $scheduleTime, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .tint(Color(hex: "#C49A6C")) + .foregroundStyle(.white) + .onChange(of: scheduleTime) { _, newValue in + let mins = Self.minutes(from: newValue) + guard mins != scene.scheduleMinutesSinceMidnight else { return } + viewModel.setSchedule(minutesSinceMidnight: mins, on: scene) + } + .padding(16) + + Divider().overlay(Color.white.opacity(0.08)) + Text(scheduleHint) + .font(.system(size: 13)) + .foregroundStyle(Color.white.opacity(0.45)) + .padding(16) + } } + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 16)) } private var geofenceHint: String { @@ -145,6 +204,16 @@ struct SceneDetailView: View { } } + private var scheduleHint: String { + let formatted = scheduleTime.formatted(date: .omitted, time: .shortened) + return "Runs automatically at \(formatted) every day. It fires on its own β€” you'll get a notification each time." + } + + private static func minutes(from date: Date) -> Int { + let comps = Calendar.current.dateComponents([.hour, .minute], from: date) + return (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + } + // MARK: - Actions private var actionsSection: some View { diff --git a/Lumen/Features/Scenes/SceneViewModel.swift b/Lumen/Features/Scenes/SceneViewModel.swift index 8c74db0..398873f 100644 --- a/Lumen/Features/Scenes/SceneViewModel.swift +++ b/Lumen/Features/Scenes/SceneViewModel.swift @@ -85,6 +85,15 @@ final class SceneViewModel { } } + /// Sets (minutes since midnight) or clears (nil) a scene's daily schedule. + func setSchedule(minutesSinceMidnight: Int?, on scene: Scene) { + do { + try sceneService.setSchedule(minutesSinceMidnight: minutesSinceMidnight, on: scene) + } catch { + self.error = error + } + } + func addAction( to scene: Scene, deviceID: UUID, diff --git a/Lumen/Services/NotificationService.swift b/Lumen/Services/NotificationService.swift index d07ad6e..5cbfe2b 100644 --- a/Lumen/Services/NotificationService.swift +++ b/Lumen/Services/NotificationService.swift @@ -34,6 +34,9 @@ final class NotificationService { case "departure": content.subtitle = "You left home" content.body = "'\(sceneName)' scene activated for \(deviceCount) device\(deviceCount == 1 ? "" : "s")" + case "schedule": + content.subtitle = "Ran on schedule" + content.body = "'\(sceneName)' ran on schedule β€” you set this up. Tap to adjust." default: content.body = "'\(sceneName)' scene activated" } diff --git a/Lumen/Services/Scene/SceneService.swift b/Lumen/Services/Scene/SceneService.swift index 390b520..e5ceabf 100644 --- a/Lumen/Services/Scene/SceneService.swift +++ b/Lumen/Services/Scene/SceneService.swift @@ -17,6 +17,8 @@ final class SceneService { private(set) var lastAutoExecutionEvent: (scene: Scene, event: GeofenceEvent)? private var monitoringTask: Task? + private var scheduleTask: Task? + private var lastScheduledFire: [UUID: Date] = [:] init(modelContext: ModelContext, deviceService: DeviceService) { self.modelContext = modelContext @@ -92,6 +94,70 @@ final class SceneService { } } + // MARK: - Schedule Monitoring + // Scheduled scenes fire directly β€” the user set the time ahead of time, which + // is the consent (see ROADMAP.md "Competitive watch"). Every fire posts a + // notification so the automation stays explainable and reversible. + + func startMonitoringScheduledScenes(tick: Duration = .seconds(30), grace: TimeInterval = 120) { + scheduleTask?.cancel() + scheduleTask = Task { + while !Task.isCancelled { + await handleScheduledFires(now: Date(), grace: grace) + try? await Task.sleep(for: tick) + } + } + } + + func stopMonitoringScheduledScenes() { + scheduleTask?.cancel() + scheduleTask = nil + } + + private func handleScheduledFires(now: Date, grace: TimeInterval) async { + let descriptor = FetchDescriptor() + guard let scenes = try? modelContext.fetch(descriptor) else { return } + + let due = Self.scenesDue(at: now, in: scenes, lastFired: lastScheduledFire, grace: grace) + for scene in due { + lastScheduledFire[scene.id] = now + do { + try await execute(scene) + NotificationService.shared.notifyAutomationExecuted( + sceneName: scene.name, + eventType: "schedule", + deviceCount: scene.actions.count + ) + } catch { + NotificationService.shared.notifyAutomationFailed( + sceneName: scene.name, + reason: error.localizedDescription + ) + } + } + } + + // Pure routing: which scenes are due to fire at `now`, given when each last + // fired. Testable without SwiftData, timers, or notifications. + static func scenesDue( + at now: Date, + in scenes: [Scene], + lastFired: [UUID: Date], + grace: TimeInterval = 120, + calendar: Calendar = .current + ) -> [Scene] { + scenes.filter { scene in + guard let minutes = scene.scheduleMinutesSinceMidnight else { return false } + return ScheduleTiming.isDue( + minutesSinceMidnight: minutes, + now: now, + lastFired: lastFired[scene.id], + grace: grace, + calendar: calendar + ) + } + } + // MARK: - Scene CRUD @discardableResult @@ -147,6 +213,20 @@ final class SceneService { try modelContext.save() } + /// Sets (or clears, with `nil`) a scene's daily schedule. `minutesSinceMidnight` + /// is clamped to a valid time of day. Clears the last-fired record so a newly + /// set time can still fire today. + func setSchedule(minutesSinceMidnight: Int?, on scene: Scene) throws { + if let minutes = minutesSinceMidnight { + scene.scheduleMinutesSinceMidnight = min(max(minutes, 0), 1439) + } else { + scene.scheduleMinutesSinceMidnight = nil + } + lastScheduledFire[scene.id] = nil + scene.updatedAt = Date() + try modelContext.save() + } + func deleteScene(_ scene: Scene) throws { modelContext.delete(scene) try modelContext.save() diff --git a/Lumen/Services/Scene/ScheduleTiming.swift b/Lumen/Services/Scene/ScheduleTiming.swift new file mode 100644 index 0000000..2f27fe6 --- /dev/null +++ b/Lumen/Services/Scene/ScheduleTiming.swift @@ -0,0 +1,45 @@ +import Foundation + +// MARK: - Schedule Timing +// Pure, calendar-injectable logic for daily scene schedules. Lifted out of +// SceneService so the "is this scene due to fire now?" decision can be +// unit-tested without SwiftData, timers, or notifications β€” the same convention +// as RhythmTiming / SuggestionEngine. + +enum ScheduleTiming { + + /// The moment a `minutesSinceMidnight` schedule fires on the day containing + /// `reference` (local time). Returns nil for an out-of-range value. + static func fireDate( + minutesSinceMidnight: Int, + on reference: Date, + calendar: Calendar = .current + ) -> Date? { + guard (0..<1440).contains(minutesSinceMidnight) else { return nil } + let start = calendar.startOfDay(for: reference) + return calendar.date(byAdding: .minute, value: minutesSinceMidnight, to: start) + } + + /// Whether a schedule is due to fire at `now`: + /// - `now` is at or after today's fire moment, and within `grace` of it + /// (so a scene missed by hours β€” e.g. the app was closed β€” does **not** + /// fire stale on next launch), and + /// - it has not already fired at or after today's fire moment. + /// + /// The grace window keeps firing calm and predictable: a scheduled scene runs + /// close to its time or not at all, never surprising the user long after. + static func isDue( + minutesSinceMidnight: Int, + now: Date, + lastFired: Date?, + grace: TimeInterval = 120, + calendar: Calendar = .current + ) -> Bool { + guard let fire = fireDate(minutesSinceMidnight: minutesSinceMidnight, on: now, calendar: calendar) else { + return false + } + guard now >= fire, now <= fire.addingTimeInterval(grace) else { return false } + if let lastFired, lastFired >= fire { return false } + return true + } +} diff --git a/LumenTests/ScheduleTests.swift b/LumenTests/ScheduleTests.swift new file mode 100644 index 0000000..844beae --- /dev/null +++ b/LumenTests/ScheduleTests.swift @@ -0,0 +1,104 @@ +import XCTest +import SwiftData +@testable import Lumen + +// Covers the daily-schedule trigger: the pure ScheduleTiming evaluator and +// SceneService.scenesDue routing. No timers or notifications. +final class ScheduleTests: XCTestCase { + + private let calendar = Calendar(identifier: .gregorian) + private var container: ModelContainer! + + @MainActor + private func makeScene(name: String, minutes: Int?) -> Scene { + let container = self.container ?? PersistenceCoordinator.makeInMemoryContainer() + self.container = container + let scene = Scene(name: name, scheduleMinutesSinceMidnight: minutes) + container.mainContext.insert(scene) + return scene + } + + private func date(_ y: Int, _ mo: Int, _ d: Int, _ h: Int, _ mi: Int) -> Date { + var comps = DateComponents() + comps.year = y; comps.month = mo; comps.day = d; comps.hour = h; comps.minute = mi + return calendar.date(from: comps)! + } + + // MARK: - ScheduleTiming + + func testFireDateIsMidnightPlusMinutes() { + let ref = date(2026, 7, 14, 8, 30) + let fire = ScheduleTiming.fireDate(minutesSinceMidnight: 21 * 60, on: ref, calendar: calendar) + XCTAssertEqual(fire, date(2026, 7, 14, 21, 0)) + } + + func testFireDateRejectsOutOfRange() { + let ref = date(2026, 7, 14, 8, 30) + XCTAssertNil(ScheduleTiming.fireDate(minutesSinceMidnight: -1, on: ref, calendar: calendar)) + XCTAssertNil(ScheduleTiming.fireDate(minutesSinceMidnight: 1440, on: ref, calendar: calendar)) + } + + func testDueExactlyAtFireTime() { + let now = date(2026, 7, 14, 21, 0) + XCTAssertTrue(ScheduleTiming.isDue(minutesSinceMidnight: 21 * 60, now: now, lastFired: nil, calendar: calendar)) + } + + func testNotDueBeforeFireTime() { + let now = date(2026, 7, 14, 20, 59) + XCTAssertFalse(ScheduleTiming.isDue(minutesSinceMidnight: 21 * 60, now: now, lastFired: nil, calendar: calendar)) + } + + func testDueWithinGraceButNotAfter() { + let fire = 21 * 60 + XCTAssertTrue(ScheduleTiming.isDue( + minutesSinceMidnight: fire, now: date(2026, 7, 14, 21, 1), lastFired: nil, grace: 120, calendar: calendar + )) + // 3 minutes late with a 2-minute grace β†’ missed, does not fire stale. + XCTAssertFalse(ScheduleTiming.isDue( + minutesSinceMidnight: fire, now: date(2026, 7, 14, 21, 3), lastFired: nil, grace: 120, calendar: calendar + )) + } + + func testDoesNotRefireSameDay() { + let fire = 21 * 60 + let firstFire = date(2026, 7, 14, 21, 0) + XCTAssertFalse(ScheduleTiming.isDue( + minutesSinceMidnight: fire, now: date(2026, 7, 14, 21, 1), lastFired: firstFire, grace: 120, calendar: calendar + )) + } + + func testFiresAgainNextDay() { + let fire = 21 * 60 + let yesterday = date(2026, 7, 13, 21, 0) + XCTAssertTrue(ScheduleTiming.isDue( + minutesSinceMidnight: fire, now: date(2026, 7, 14, 21, 0), lastFired: yesterday, grace: 120, calendar: calendar + )) + } + + func testMidnightSchedule() { + let now = date(2026, 7, 14, 0, 0) + XCTAssertTrue(ScheduleTiming.isDue(minutesSinceMidnight: 0, now: now, lastFired: nil, calendar: calendar)) + } + + // MARK: - scenesDue routing + + @MainActor + func testScenesDueFiltersByScheduleAndLastFired() { + let scheduled = makeScene(name: "Wind-down", minutes: 21 * 60) + let unscheduled = makeScene(name: "Movie Night", minutes: nil) + let alreadyFired = makeScene(name: "Morning", minutes: 21 * 60) + + let now = date(2026, 7, 14, 21, 0) + let lastFired = [alreadyFired.id: now] + + let due = SceneService.scenesDue( + at: now, + in: [scheduled, unscheduled, alreadyFired], + lastFired: lastFired, + grace: 120, + calendar: calendar + ) + + XCTAssertEqual(due.map(\.id), [scheduled.id]) + } +} diff --git a/ROADMAP.md b/ROADMAP.md index 1930bf5..7609a6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,3 +42,7 @@ Lumen is a solo/small-team indie iOS project (team `CU67F9EY3Q`, bundle ID `com. The broader smart-home market β€” other HomeKit-focused apps and platform-level AI expected in upcoming iOS releases β€” is moving toward fully automatic AI: agents that act on a user's behalf with little or no per-action confirmation. That's a reasonable bet for those products and platforms. Lumen's bet is the opposite: explainability and mandatory consent are the product, not a missing feature. As the market converges on "more automatic," the gap between "automatic" and "explained, approved, calm" gets more visible β€” that gap is the moat. Roadmap decisions should protect it: **do not add an "auto-apply without confirmation" mode, even as a power-user opt-in, without revisiting this document first.** + +### Decision log β€” scheduled scenes fire directly (July 2026) + +Scene schedules (`Scene.scheduleMinutesSinceMidnight`) run their scene **directly** at the set time, without a per-fire confirmation sheet. This is deliberately **not** a violation of the consent principle above: setting a specific scene to run at a specific time is itself an explicit, ahead-of-time consent β€” categorically different from the forbidden "auto-apply on a hunch," where Lumen infers intent and acts. To keep it explainable and reversible, every scheduled fire posts a notification ("… ran on schedule β€” you set this up. Tap to adjust."), and a `grace` window (`ScheduleTiming`) means a missed schedule does not fire stale hours later. Geofence-triggered scenes already followed this same "pre-authorized trigger fires directly + notifies" model. Ambient/inferred suggestions (`SuggestionEngine` β†’ `LumenReasoningView` β†’ `LumenActionView`) remain confirmation-gated and must stay that way.