Repository: StanfordSpezi/SpeziScheduler
Pull request #70: `observeNewOutcomes` observation API, fix `.once` schedules
Author: login: lukaskollmer
Labels: name: enhancement
description: New feature or request
Created: 2025-05-07T18:39:19Z | Merged: 2025-05-12T08:30:54Z

# `observeNewOutcomes` observation API, fix `.once` schedules

## :recycle: Current situation & Problem
This PR marks the Scheduler's `sinkDidSavePublisher(into:)` function as `@_spi(APISupport) public`, thereby allowing other Spezi packages to observe changes to the scheduler's internal ModelContext.
The specific use case here is SpeziStudy wanting to be able to run code in response to specific `Event`s getting marked as completed.
It also adds a new `@_spi(APISupport) public func observeNewOutcomes(_ handler: @MainActor @escaping (Outcome) -> Void) -> AnyObject` to the Scheduler, which allows registering a callback that will get invoked every time a new `Outome` is added to the scheduler.
Note: This API is intentionally closure-based, rather than being eg an `AsyncStream` as discussed below. The reason for this is that `Outcome` isn't sendable, but the `AsyncStream.Continuation`'s yield function operates on a `sending` parameter.

Additionally, this PR attempts to fix #56 and fix #64.

Additionally, this PR attempts to work around https://github.com/swiftlang/swift-foundation/issues/1283, which currently results in using eg `Date.now` as the start date of some `Schedule`s producing incorrect schedule occurrences.

### Alternatives considered
Instead of directly exposing the "raw" did change notification (which IMO we should still do, anwyay), we could also give the `Scheduler` directly a nice API for observing changes to it's underlying SwiftData model.
For example, we could have a function `func completions(of task: Task) -> some AsyncSequence<Outcome, Never>` that returns an `AsyncStream` which yields individual `Outcomes` for the task, as they are saved into the context.
(The immediate first issue here would bw how this should handle new Task versions getting introduced while there still are active observers on the now-outdated previous version...)
IMO we should go with the simpler change from this PR first, and then maybe add a more sophisticated API in the future, should we need to.

## :gear: Release Notes
- add `@_spi(APISupport) public func observeNewOutcomes(_ handler: @MainActor @escaping (Outcome) -> Void) -> AnyObject`
- restored the ability to use `.once` schedules (it no longer crashes)
- removed several `Duration` extensions (e.g.: `Duration.days(_:)`, `.weeks(_:)`, etc). these have been moved to [SpeziFoundation](https://github.com/StanfordSpezi/SpeziFoundation)


## :books: Documentation
is documented


## :white_check_mark: Testing
- the UITests now schedule a `.once` task; the fact that this isn't crashing the app is in itself already a regression test
- additionally, we have a View that registers a new-outcome observer with the scheduler, completes an event, and checks that the observer has been called with an outcome belonging to the just-completed event


## :pencil: Code of Conduct & Contributing Guidelines
By creating and submitting this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).


Linked issue #56: Schedule w/ interval > 1 throws fatal exception
### Description

I am just starting to explore the Spezi framework by making a few modifications to the template application.  I tried to add a new scheduled task for the GAD-7 questionnaire with a bi-weekly interval.  I used this schedule:

let lastMonth = Calendar.current.date(byAdding: .month, value: -1, to: .today) ?? .today
schedule: .weekly(interval: 2, weekday: .saturday, hour: 8, minute: 0, startingAt: lastMonth)

I get a fatal exception that exits the app (running in a simulator).  Using interval=1 works without error.

It's difficult to trace the cause that occurs in notification while the app is first starting.  Here is the console error:

typeMismatch(SpeziScheduler.Schedule.NotificationMatchingHint, Swift.DecodingError.Context(codingPath: [], debugDescription: "Invalid number of keys found, expected one.", underlyingError: nil))

### Reproduction

Modify the template application task schedule as follows:

let lastMonth = Calendar.current.date(byAdding: .month, value: -1, to: .today) ?? .today
schedule: .weekly(interval: 2, weekday: .saturday, hour: 8, minute: 0, startingAt: lastMonth)

### Expected behavior

Fatal app exit on startup while running in simulator.

### Additional context

_No response_

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct and Contributing Guidelines
Linked issue comments: user: login: PSchmiedmayer
created_at: 2025-02-11T04:41:47Z
body: @Supereg Tagging you here for some further investigation 👍
author_association: MEMBER
user: login: PSchmiedmayer
created_at: 2025-03-02T19:58:52Z
body: @Supereg Following up on this with the latest changes, did you have a chance to take a look at this?
author_association: MEMBER
user: login: bauer-andreas
created_at: 2025-03-09T16:56:28Z
body: @PSchmiedmayer I didn't yet. I'll try to have a look later today. If I don't manage I'll definitely prioritize this for the week 👍 
author_association: CONTRIBUTOR
user: login: PSchmiedmayer
created_at: 2025-03-09T18:09:24Z
body: Thank you @Supereg 🚀
author_association: MEMBER

Diff:
diff --git a/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift b/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift
index c7c3982..749fc0b 100644
--- a/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift
+++ b/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift
@@ -12,11 +12,14 @@ import UserNotifications
 
 extension Schedule {
     enum NotificationMatchingHint: Codable, Sendable, Hashable {
+        case none
         case components(hour: Int, minute: Int, second: Int, weekday: Int?)
         case allDayNotification(weekday: Int?)
         
-        func dateComponents(calendar: Calendar, allDayNotificationTime: NotificationTime) -> DateComponents {
+        func dateComponents(calendar: Calendar, allDayNotificationTime: NotificationTime) -> DateComponents? {
             switch self {
+            case .none:
+                return nil
             case let .components(hour, minute, second, weekday):
                 return DateComponents(calendar: calendar, hour: hour, minute: minute, second: second, weekday: weekday)
             case let .allDayNotification(weekday):
@@ -48,11 +51,10 @@ extension Schedule {
         second: Int,
         weekday: Int? = nil, // swiftlint:disable:this function_default_parameter_at_end
         consider duration: Duration
-    ) -> NotificationMatchingHint? {
+    ) -> NotificationMatchingHint {
         guard interval == 1 else {
-            return nil
+            return .none
         }
-
         if duration.isAllDay {
             return .allDayNotification(weekday: weekday)
         } else {
@@ -61,7 +63,7 @@ extension Schedule {
     }
 
     func canBeScheduledAsRepeatingCalendarTrigger(allDayNotificationTime: NotificationTime, now: Date = .now) -> Bool {
-        guard let notificationMatchingHint, let recurrence else {
+        guard notificationMatchingHint != .none, let recurrence else {
             return false // needs to be repetitive and have a interval hint
         }
 
@@ -70,7 +72,12 @@ extension Schedule {
         }
 
         // otherwise, check if it still works (e.g., we have Monday, start date is Wednesday and schedule reoccurs every Friday).
-        let components = notificationMatchingHint.dateComponents(calendar: recurrence.calendar, allDayNotificationTime: allDayNotificationTime)
+        guard let components = notificationMatchingHint.dateComponents(
+            calendar: recurrence.calendar,
+            allDayNotificationTime: allDayNotificationTime
+        ) else {
+            return false
+        }
         let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
         guard let nextDate = trigger.nextTriggerDate() else {
             return false
diff --git a/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift b/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift
index 5def67b..c8ad8f1 100644
--- a/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift
+++ b/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift
@@ -444,15 +444,15 @@ extension SchedulerNotifications {
                 }
                 if upcomingEventsForCurrentTask.count > 1,
                    Set(eventsDistances).count == 1,
-                   let hint = event.task.schedule.notificationMatchingHint,
-                   event.task.schedule.canBeScheduledAsRepeatingCalendarTrigger(allDayNotificationTime: allDayNotificationTime, now: now) {
+                   case let hint = event.task.schedule.notificationMatchingHint,
+                   hint != .none,
+                   event.task.schedule.canBeScheduledAsRepeatingCalendarTrigger(allDayNotificationTime: allDayNotificationTime, now: now),
+                   let triggerDateComponents = hint.dateComponents(calendar: event.task.schedule.recurrence?.calendar ?? cal, allDayNotificationTime: allDayNotificationTime) { // swiftlint:disable:this line_length
                     // ... if they are (and we actually have multiple events), we can schedule them via a single, repeating UNCalendarNotificationTrigger ...
                     let content = event.task.notificationContent()
                     if let standard = standard as? any SchedulerNotificationsConstraint {
                         standard.notificationContent(for: event.task, content: content)
                     }
-                    let cal = event.task.schedule.recurrence?.calendar ?? cal
-                    let triggerDateComponents = hint.dateComponents(calendar: cal, allDayNotificationTime: allDayNotificationTime)
                     let trigger = UNCalendarNotificationTrigger(
                         dateMatching: triggerDateComponents,
                         repeats: true
diff --git a/Sources/SpeziScheduler/Schedule/Duration+Extensions.swift b/Sources/SpeziScheduler/Schedule/Duration+Extensions.swift
deleted file mode 100644
index 0e76503..0000000
--- a/Sources/SpeziScheduler/Schedule/Duration+Extensions.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-//
-// This source file is part of the Stanford Spezi open-source project
-//
-// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
-//
-// SPDX-License-Identifier: MIT
-//
-
-
-extension Duration {
-    /// A duration given a number of minutes.
-    ///
-    /// ```swift
-    /// let duration: Duration = .minutes(27)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of minutes.
-    @inlinable
-    public static func minutes(_ minutes: some BinaryInteger) -> Duration {
-        .seconds(minutes * 60)
-    }
-
-    /// A duration given a number of minutes.
-    ///
-    /// Creates a new duration given a number of minutes by converting into the closest second scale value.
-    ///
-    /// ```swift
-    /// let duration: Duration = .minutes(27.5)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of minutes.
-    @inlinable
-    public static func minutes(_ minutes: Double) -> Duration {
-        .seconds(minutes * 60)
-    }
-
-    /// A duration given a number of hours.
-    ///
-    /// ```swift
-    /// let duration: Duration = .hours(4)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of hours.
-    @inlinable
-    public static func hours(_ hours: some BinaryInteger) -> Duration {
-        .seconds(hours * 60 * 60)
-    }
-
-    /// A duration given a number of hours.
-    ///
-    /// Creates a new duration given a number of hours by converting into the closest second scale value.
-    ///
-    /// ```swift
-    /// let duration: Duration = .hours(4.5)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of hours.
-    @inlinable
-    public static func hours(_ hours: Double) -> Duration {
-        .seconds(hours * 60 * 60)
-    }
-
-    /// A duration given a number of days.
-    ///
-    /// ```swift
-    /// let duration: Duration = .days(2)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of days.
-    @inlinable
-    public static func days(_ days: some BinaryInteger) -> Duration {
-        .seconds(days * 60 * 60 * 24)
-    }
-
-    /// A duration given a number of days.
-    ///
-    /// ```swift
-    /// let duration: Duration = .days(2.5)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of days.
-    @inlinable
-    public static func days(_ days: Double) -> Duration {
-        .seconds(days * 60 * 60 * 24)
-    }
-
-    /// A duration given a number of weeks.
-    ///
-    /// ```swift
-    /// let duration: Duration = .weeks(4)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of weeks.
-    @inlinable
-    public static func weeks(_ weeks: some BinaryInteger) -> Duration {
-        .seconds(weeks * 60 * 60 * 24 * 7)
-    }
-
-    /// A duration given a number of weeks.
-    ///
-    /// ```swift
-    /// let duration: Duration = .weeks(3.5)
-    /// ```
-    /// - Returns: A `Duration` representing a given number of weeks.
-    @inlinable
-    public static func weeks(_ weeks: Double) -> Duration {
-        .seconds(weeks * 60 * 60 * 24 * 7)
-    }
-}
diff --git a/Sources/SpeziScheduler/Schedule/Schedule.swift b/Sources/SpeziScheduler/Schedule/Schedule.swift
index 39056f7..0617b80 100644
--- a/Sources/SpeziScheduler/Schedule/Schedule.swift
+++ b/Sources/SpeziScheduler/Schedule/Schedule.swift
@@ -61,7 +61,7 @@ public struct Schedule {
     ///
     /// This is only possible if `DateComponents` doesn't describe another occurrence between `now` and `startDate`. Therefore, we might need to schedule
     /// these occurrences manually, till we reach a date where `now` is near enough at the `startDate`.
-    private(set) var notificationMatchingHint: NotificationMatchingHint?
+    private(set) var notificationMatchingHint: NotificationMatchingHint = .none
 
     /// The duration of a single occurrence.
     ///
@@ -90,9 +90,8 @@ public struct Schedule {
         }
         set {
             recurrenceRule = newValue.map { Data(encoding: $0) }
-
             // if someone updates the recurrence rule, our notificationMatchingHint is not valid anymore
-            notificationMatchingHint = nil
+            notificationMatchingHint = .none
         }
     }
 
@@ -137,10 +136,10 @@ public struct Schedule {
         startingAt start: Date,
         duration: Duration,
         recurrence: Calendar.RecurrenceRule?,
-        notificationIntervalHint: NotificationMatchingHint?
+        notificationIntervalHint: NotificationMatchingHint
     ) {
         self.duration = duration
-        self.start = start
+        self.start = .init(timeIntervalSince1970: floor(start.timeIntervalSince1970))
         self.recurrence = recurrence
         self.notificationMatchingHint = notificationIntervalHint
     }
@@ -162,7 +161,7 @@ public struct Schedule {
     ///   - duration: The duration of a single occurrence.
     ///   - recurrence: Optional recurrence rule to specify how often and in which interval the event my reoccur.
     public init(startingAt start: Date, duration: Duration = .tillEndOfDay, recurrence: Calendar.RecurrenceRule? = nil) {
-        self.init(startingAt: start, duration: duration, recurrence: recurrence, notificationIntervalHint: nil)
+        self.init(startingAt: start, duration: duration, recurrence: recurrence, notificationIntervalHint: .none)
     }
 
 
diff --git a/Sources/SpeziScheduler/Scheduler.swift b/Sources/SpeziScheduler/Scheduler.swift
index d514685..32d1a5f 100644
--- a/Sources/SpeziScheduler/Scheduler.swift
+++ b/Sources/SpeziScheduler/Scheduler.swift
@@ -133,7 +133,9 @@ public final class Scheduler: Module, EnvironmentAccessible, DefaultInitializabl
     
     /// A task that slightly delays saving tasks.
     private var saveTask: _Concurrency.Task<Void, Never>?
-    
+    /// Observer closures that are called in response to new ``Outcome``s being added to the Scheduler.
+    /// - Note: the `UUID` only serves as a means of identifying the individual closures, so that we can remove them upon termination
+    private var outcomeObservers: [UUID: @MainActor (Outcome) -> Void] = [:]
     /// Creates a new Scheduler, using on-disk persistence.
     public nonisolated convenience init() {
         self.init(persistence: .onDisk)
@@ -352,6 +354,9 @@ public final class Scheduler: Module, EnvironmentAccessible, DefaultInitializabl
             for: context,
             rescheduleNotifications: outcome.task.scheduleNotifications && outcome.occurrenceStartDate > .now
         )
+        for (_, handler) in outcomeObservers {
+            handler(outcome)
+        }
     }
 }
 
@@ -426,9 +431,9 @@ extension Scheduler {
     /// - Parameter taskId: The task id for which you want to delete all versions. Refer to ``Task/id``.
     public func deleteAllVersions(ofTask taskId: String) throws {
         let context = try context
-        try context.delete(model: Task.self, where: #Predicate { task in
-            task.id == taskId
-        })
+        for task in try context.fetch(FetchDescriptor(predicate: #Predicate<Task> { $0.id == taskId })) {
+            context.delete(task)
+        }
         scheduleSave(for: context, rescheduleNotifications: true)
     }
 }
@@ -595,13 +600,13 @@ extension Scheduler {
     private struct OccurrenceId: Hashable {
         let taskId: Task.ID
         let startDate: Date
-
+        
         init(task: Task, startDate: Date) {
             self.taskId = task.id
             self.startDate = startDate
         }
     }
-
+    
     func assembleEvents(
         for range: Range<Date>,
         tasks: some Sequence<Task>,
@@ -648,27 +653,52 @@ extension Scheduler {
                 lhs.occurrence < rhs.occurrence
             }
     }
-
+    
     func hasEventOccurrence(in range: Range<Date>, tasks: some Sequence<Task>) -> Bool {
         tasks
             .lazy
             .compactMap { $0.schedule.nextOccurrence(in: range) }
             .contains { _ in true }
     }
-
+    
     func queryEventsAnchor(
         for range: Range<Date>,
         predicate taskPredicate: Predicate<Task> = #Predicate { _ in true }
     ) throws -> Set<PersistentIdentifier> {
         let taskIdentifier = try queryTaskIdentifiers(with: inRangePredicate(for: range), combineWith: taskPredicate)
         let outcomeIdentifiers = try queryOutcomeIdentifiers(for: range, predicate: taskPredicate)
-
+        
         return taskIdentifier.union(outcomeIdentifiers)
     }
+}
 
-    func sinkDidSavePublisher(into consume: @escaping (Notification) -> Void) throws -> AnyCancellable {
-        let context = try context
 
+extension Scheduler {
+    @MainActor
+    public final class OutcomeSubscription: Sendable {
+        private let id: UUID
+        private nonisolated(unsafe) weak var scheduler: Scheduler? // safe as reference counting is atomic and we do not mutate otherwise
+        
+        init(id: UUID, scheduler: Scheduler) {
+            self.id = id
+            self.scheduler = scheduler
+        }
+        
+        deinit {
+            guard let scheduler else {
+                return
+            }
+            let id = id
+            _Concurrency.Task { @MainActor in
+                scheduler.outcomeObservers.removeValue(forKey: id)
+            }
+        }
+    }
+    
+    /// Subscribes to save events on the scheduler's internal SwiftData ModelContext, using the specified closure.
+    @_spi(APISupport)
+    public func sinkDidSavePublisher(into consume: @escaping @MainActor (Notification) -> Void) throws -> AnyCancellable {
+        let context = try context
         return NotificationCenter.default.publisher(for: ModelContext.didSave, object: context)
             .sink { notification in
                 // We use the mainContext. Therefore, the vent will always be called from the main actor
@@ -677,6 +707,17 @@ extension Scheduler {
                 }
             }
     }
+    
+    /// Registers an observer function that gets called when new ``Outcome``s are created within the Scheduler.
+    ///
+    /// - parameter handler: A closure which will get invoked every time a new ``Outcome`` is added to the ``Scheduler``.
+    /// - returns: An opaque handle, to which the lifetime of the observation is tied.
+    @_spi(APISupport)
+    public func observeNewOutcomes(_ handler: @MainActor @escaping (Outcome) -> Void) -> OutcomeSubscription {
+        let id = UUID()
+        outcomeObservers[id] = handler
+        return OutcomeSubscription(id: id, scheduler: self)
+    }
 }
 
 // MARK: - Fetch Implementations
diff --git a/Sources/SpeziSchedulerUI/EventActionButton.swift b/Sources/SpeziSchedulerUI/EventActionButton.swift
index 04e209e..df94b87 100644
--- a/Sources/SpeziSchedulerUI/EventActionButton.swift
+++ b/Sources/SpeziSchedulerUI/EventActionButton.swift
@@ -25,22 +25,20 @@ public struct EventActionButton: View {
     @ManagedViewUpdate private var actionUpdate
 
 
-    private var actionDisabled: Bool {
+    private var isEventCompletionAllowed: Bool {
         let policy = event.task.completionPolicy
-
         let now = Date.now
-        let disabled = policy.isAllowedToComplete(event: event, now: now)
-        if disabled {
-            if let completionAllowed = policy.dateOnceCompletionIsAllowed(for: event, now: now) {
-                actionUpdate.schedule(at: completionAllowed)
+        let allowed = policy.isAllowedToComplete(event: event, now: now)
+        if allowed {
+            if let dateWhenDisallowed = policy.dateOnceCompletionBecomesDisallowed(for: event, now: now), dateWhenDisallowed != .distantFuture {
+                actionUpdate.schedule(at: dateWhenDisallowed)
             }
         } else {
-            if let completionDisallowed = policy.dateOnceCompletionBecomesDisallowed(for: event, now: now) {
-                actionUpdate.schedule(at: completionDisallowed)
+            if let dateWhenAllowed = policy.dateOnceCompletionIsAllowed(for: event, now: now), dateWhenAllowed != .distantPast {
+                actionUpdate.schedule(at: dateWhenAllowed)
             }
         }
-
-        return false
+        return allowed
     }
 
     private var actionLabel: Text {
@@ -59,8 +57,8 @@ public struct EventActionButton: View {
             actionLabel
                 .frame(maxWidth: .infinity, minHeight: 30)
         }
-            .disabled(actionDisabled)
-            .buttonStyle(.borderedProminent)
+        .buttonStyle(.borderedProminent)
+        .disabled(!isEventCompletionAllowed)
     }
 
     init(event: Event, label: Text?, action: @escaping () -> Void) {
diff --git a/Tests/SpeziSchedulerTests/ScheduleTests.swift b/Tests/SpeziSchedulerTests/ScheduleTests.swift
index ccf89ea..d730f75 100644
--- a/Tests/SpeziSchedulerTests/ScheduleTests.swift
+++ b/Tests/SpeziSchedulerTests/ScheduleTests.swift
@@ -98,6 +98,34 @@ final class ScheduleTests: XCTestCase {
         let occurrence1 = occurrences[1]
         try XCTAssertEqual(occurrence1.start, .withTestDate(day: 25, hour: 12, minute: 35, second: 0))
     }
+    
+    func testDailyScheduleWithOneOccurrence() throws {
+        let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25)
+        let schedule: Schedule = .daily(hour: 12, minute: 35, startingAt: startDate, end: .afterOccurrences(1), duration: .minutes(30))
+
+        let occurrences = schedule.occurrences()
+        var iterator = occurrences.makeIterator()
+
+        let occurrence1 = try XCTUnwrap(iterator.next())
+        try XCTAssertEqual(occurrence1.start, .withTestDate(hour: 12, minute: 35))
+        try XCTAssertEqual(occurrence1.end, .withTestDate(hour: 13, minute: 5))
+
+        XCTAssertNil(iterator.next())
+    }
+    
+    func testDailyScheduleWithOneOccurrenceSubSecondStartDate() throws {
+        let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25, nanosecond: 527)
+        let schedule: Schedule = .daily(hour: 12, minute: 35, startingAt: startDate, end: .afterOccurrences(1), duration: .minutes(30))
+
+        let occurrences = schedule.occurrences()
+        var iterator = occurrences.makeIterator()
+
+        let occurrence1 = try XCTUnwrap(iterator.next())
+        try XCTAssertEqual(occurrence1.start, .withTestDate(hour: 12, minute: 35))
+        try XCTAssertEqual(occurrence1.end, .withTestDate(hour: 13, minute: 5))
+
+        XCTAssertNil(iterator.next())
+    }
 
     func testDailyScheduleWithThreeOccurrences() throws {
         let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25)
@@ -154,9 +182,9 @@ final class ScheduleTests: XCTestCase {
 
 
 extension Date {
-    static func withTestDate(year: Int = 2024, month: Int = 8, day: Int = 24, hour: Int, minute: Int, second: Int = 0) throws -> Date {
-        // swiftlint:disable:previous function_default_parameter_at_end
-        let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute, second: second)
+    static func withTestDate(year: Int = 2024, month: Int = 8, day: Int = 24, hour: Int, minute: Int, second: Int = 0, nanosecond: Int = 0) throws -> Date {
+        // swiftlint:disable:previous function_default_parameter_at_end line_length
+        let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute, second: second, nanosecond: nanosecond)
         return try XCTUnwrap(Calendar.current.date(from: components), "Invalid test date")
     }
 }
diff --git a/Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift b/Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
new file mode 100644
index 0000000..76af388
--- /dev/null
+++ b/Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
@@ -0,0 +1,53 @@
+//
+// This source file is part of the Stanford Spezi open-source project
+//
+// SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
+//
+// SPDX-License-Identifier: MIT
+//
+
+import Foundation
+import Spezi
+@_spi(APISupport)
+import SpeziScheduler
+import SpeziSchedulerUI
+import SwiftUI
+
+
+struct ObserveNewOutcomesTestingView: View {
+    @Environment(Scheduler.self)
+    private var scheduler
+    
+    @EventQuery(in: Calendar.current.rangeOfDay(for: .now))
+    private var events
+    
+    @State private var observationToken: AnyObject?
+    @State private var didTrigger = false
+    
+    var body: some View {
+        Form {
+            Section {
+                LabeledContent("did trigger", value: didTrigger.description)
+            }
+            Section {
+                if let event = events.first(where: { $0.task.id == "TESTTESTTEST" }) {
+                    InstructionsTile(event) {
+                        _ = try? event.complete()
+                    } more: {
+                        EventDetailView(event)
+                    }
+                }
+            }
+        }
+        .onAppear {
+            guard observationToken == nil else {
+                return
+            }
+            observationToken = scheduler.observeNewOutcomes { outcome in
+                if outcome.task.id == "TESTTESTTEST" {
+                    didTrigger = true
+                }
+            }
+        }
+    }
+}
diff --git a/Tests/UITests/TestApp/ScheduleView.swift b/Tests/UITests/TestApp/ScheduleView.swift
index eb9e79d..18e1d7a 100644
--- a/Tests/UITests/TestApp/ScheduleView.swift
+++ b/Tests/UITests/TestApp/ScheduleView.swift
@@ -20,13 +20,16 @@ enum DateSelection: Hashable {
 
 enum AdditionalTestsTestCase: String, CaseIterable, Hashable, Identifiable {
     case shadowedOutcomes = "Shadowed Outcomes"
+    case observeNewOutcomes = "Observe New Outcomes"
     
     var id: Self { self }
     
-    var view: some View {
+    @ViewBuilder @MainActor var view: some View {
         switch self {
         case .shadowedOutcomes:
             ShadowedOutcomeTestingView()
+        case .observeNewOutcomes:
+            ObserveNewOutcomesTestingView()
         }
     }
 }
diff --git a/Tests/UITests/TestApp/TestAppScheduler.swift b/Tests/UITests/TestApp/TestAppScheduler.swift
index c7990f6..42c5a95 100644
--- a/Tests/UITests/TestApp/TestAppScheduler.swift
+++ b/Tests/UITests/TestApp/TestAppScheduler.swift
@@ -91,6 +91,17 @@ final class TestAppScheduler: Module {
                 scheduleNotifications: true,
                 shadowedOutcomesHandling: .delete
             )
+            
+            try scheduler.createOrUpdateTask(
+                id: "TESTTESTTEST",
+                title: "TESTTESTTEST",
+                instructions: "TESTTESTTEST",
+                category: .none,
+                schedule: .once(at: .today, duration: .tillEndOfDay),
+                completionPolicy: .sameDay,
+                scheduleNotifications: false,
+                shadowedOutcomesHandling: .delete
+            )
         } catch {
             logger.error("Failed to scheduled TestApp tasks: \(error)")
             model.viewState = .error(AnyLocalizedError(
diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift
index 5f56158..cd7d1b1 100644
--- a/Tests/UITests/TestAppUITests/TestAppUITests.swift
+++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift
@@ -32,7 +32,7 @@ class TestAppUITests: XCTestCase {
         XCTAssert(app.staticTexts["4:00 PM"].exists)
 
         XCTAssert(app.buttons["More Information"].exists)
-        app.buttons["More Information"].firstMatch.tap()
+        app.buttons.matching(identifier: "More Information").element(boundBy: 1).tap()
 
         XCTAssertTrue(app.navigationBars.staticTexts["More Information"].waitForExistence(timeout: 4.0))
         XCTAssertTrue(app.staticTexts["Instructions"].exists)
@@ -210,6 +210,28 @@ class TestAppUITests: XCTestCase {
         
         XCTAssertTrue(app.staticTexts["Passed"].waitForExistence(timeout: 2))
     }
+    
+    
+    @MainActor
+    func testObserveOutcomes() throws {
+        let app = XCUIApplication()
+        app.deleteAndLaunch(withSpringboardAppName: "TestApp")
+
+        XCTAssert(app.wait(for: .runningForeground, timeout: 2.0))
+        
+        let menuButton = app.buttons["Extra Tests"]
+        XCTAssert(menuButton.waitForExistence(timeout: 2))
+        menuButton.tryToTapReallySoftlyMaybeThisWillMakeItWork()
+        let testCaseButton = app.buttons["Observe New Outcomes"]
+        XCTAssert(testCaseButton.waitForExistence(timeout: 2))
+        testCaseButton.tap()
+        
+        XCTAssert(app.staticTexts["did trigger, false"].waitForExistence(timeout: 2))
+        app.buttons["Complete"].firstMatch.tap()
+        XCTAssert(app.staticTexts["did trigger, false"].waitForNonExistence(timeout: 2))
+        XCTAssert(app.staticTexts["did trigger, true"].waitForExistence(timeout: 2))
+        XCTAssert(app.buttons["Complete"].waitForNonExistence(timeout: 2))
+    }
 }
 
 
diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj
index c58b487..a3b195b 100644
--- a/Tests/UITests/UITests.xcodeproj/project.pbxproj
+++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj
@@ -18,6 +18,7 @@
 		80328A772D6CE4E60050ECB6 /* XCTSpeziNotificationsUI in Frameworks */ = {isa = PBXBuildFile; productRef = 80328A762D6CE4E60050ECB6 /* XCTSpeziNotificationsUI */; };
 		805BAA032D79C23A00115B39 /* ShadowedOutcomeTestingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805BAA022D79C23A00115B39 /* ShadowedOutcomeTestingView.swift */; };
 		805BAA052D79C2E700115B39 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 805BAA042D79C2E700115B39 /* XCTestApp */; };
+		80BA02F62DCC08F10000D086 /* ObserveNewOutcomesTestingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BA02F52DCC08F10000D086 /* ObserveNewOutcomesTestingView.swift */; };
 		A926A5352C9D87B100C92F94 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A926A5342C9D87B100C92F94 /* NotificationsView.swift */; };
 		A977F6732C92F4C00071A1D1 /* SpeziSchedulerUI in Frameworks */ = {isa = PBXBuildFile; productRef = A977F6722C92F4C00071A1D1 /* SpeziSchedulerUI */; };
 		A98B09C42C90913F0076E99A /* TestAppScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98B09C32C9091390076E99A /* TestAppScheduler.swift */; };
@@ -46,6 +47,7 @@
 		2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = "<group>"; };
 		2FE0B6E52A14C64E00818AE9 /* SpeziScheduler */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziScheduler; path = ../..; sourceTree = "<group>"; };
 		805BAA022D79C23A00115B39 /* ShadowedOutcomeTestingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowedOutcomeTestingView.swift; sourceTree = "<group>"; };
+		80BA02F52DCC08F10000D086 /* ObserveNewOutcomesTestingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserveNewOutcomesTestingView.swift; sourceTree = "<group>"; };
 		A926A5342C9D87B100C92F94 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
 		A960461B2C9840E800EA8022 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
 		A960461C2C9853C700EA8022 /* TestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TestApp.entitlements; sourceTree = "<group>"; };
@@ -114,6 +116,7 @@
 				2F9F4D8429B80A1500ABE259 /* TestAppDelegate.swift */,
 				A98B09C32C9091390076E99A /* TestAppScheduler.swift */,
 				805BAA022D79C23A00115B39 /* ShadowedOutcomeTestingView.swift */,
+				80BA02F52DCC08F10000D086 /* ObserveNewOutcomesTestingView.swift */,
 				2F6D139928F5F386007C25D6 /* Assets.xcassets */,
 			);
 			path = TestApp;
@@ -256,6 +259,7 @@
 				2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */,
 				805BAA032D79C23A00115B39 /* ShadowedOutcomeTestingView.swift in Sources */,
 				A926A5352C9D87B100C92F94 /* NotificationsView.swift in Sources */,
+				80BA02F62DCC08F10000D086 /* ObserveNewOutcomesTestingView.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};


Reviews:
user: login: PSchmiedmayer
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T18:45:00Z
user: login: PSchmiedmayer
body: Nice; thank you for the improvements and updates! 🚀
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T21:54:46Z
user: login: bauer-andreas
body: Left a few comments here and there. I think the overall approach of fixing the mentioned crash should work (TM).
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T18:06:12Z
user: login: lukaskollmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T18:14:28Z
user: login: lukaskollmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T18:19:14Z
user: login: bauer-andreas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:11:19Z
user: login: bauer-andreas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:12:42Z
user: login: lukaskollmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T08:58:31Z
user: login: lukaskollmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T09:15:26Z
user: login: lukaskollmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T09:40:24Z
user: login: lukaskollmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T10:03:32Z

Review comments:
diff_hunk: @@ -12,11 +12,14 @@ import UserNotifications
 
 extension Schedule {
     enum NotificationMatchingHint: Codable, Sendable, Hashable {
+        case none
path: Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift
user: login: bauer-andreas
body: Assuming SwiftData sees this as an additive change and given that the existing way of modeling it with `Optional` resulted in a crash, we can consider "removing" the `Optional` model to be a non-breaking change, right?
At this point I really do not trust SwiftData to do a great job at automatic migrations (especially across older iOS versions). But I assume you have tested this with a existing data store as well?
created_at: 2025-05-08T17:52:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -426,9 +431,9 @@ extension Scheduler {
     /// - Parameter taskId: The task id for which you want to delete all versions. Refer to ``Task/id``.
     public func deleteAllVersions(ofTask taskId: String) throws {
         let context = try context
-        try context.delete(model: Task.self, where: #Predicate { task in
-            task.id == taskId
-        })
+        for task in try context.fetch(FetchDescriptor(predicate: #Predicate<Task> { $0.id == taskId })) {
+            try context.delete(task)
path: Sources/SpeziScheduler/Scheduler.swift
user: login: bauer-andreas
body: Not sure if I'm missing any context, but why are we doing this?
Wouldn't this change now allow for inconsistent state, where one of the `delete` throws and therefore we end up in a  state where the first few tasks got deleted, but the rest of them didn't. Once we fail to remove one model, wouldn't we need to "rollback"?
created_at: 2025-05-08T17:54:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -677,6 +683,21 @@ extension Scheduler {
                 }
             }
     }
+    
+    /// Registers an observer function that gets called when new ``Outcome``s are created within the Scheduler.
+    ///
+    /// - parameter handler: A closure which will get invoked every time a new ``Outcome`` is added to the ``Scheduler``.
+    /// - returns: An opaque handle, to which the lifetime of the observation is tied.
+    @_spi(APISupport)
+    public func observeNewOutcomes(_ handler: @MainActor @escaping (Outcome) -> Void) -> AnyObject {
path: Sources/SpeziScheduler/Scheduler.swift
user: login: bauer-andreas
body: I don't think returning an `AnyObject` is a great design. Especially as we loose any sort of `Sendable` guarantees, we at least would need `AnyObject & Sendable`.

However, I think a design like the following, would be way better. No need to store a reference to a closure. No need to manually spawn an unstructured task.

We could of course explore non-copyable types for this as well to get init-like functionality, but keep the value types semantics. But this would probably be a bit more restrictive at the call site.

```swift
@MainActor
public final class OutcomeSubscription: Sendable {
  private let id: UUID
  private nonisolated(unsafe) weak var scheduler: Scheduler // safe as reference counting is atomic and we do not mutate otherwise 

  init(id: UUID, scheduler: Scheduler) {
    self.id = id
    self.scheduler = scheduler
  }

  deinit {
    scheduler?.outcomeObservers.removeValue(forKey: id) // add some file private API?
  }
}
```
created_at: 2025-05-08T18:01:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+//
+// This source file is part of the Stanford Spezi open-source project
+//
+// SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
+//
+// SPDX-License-Identifier: MIT
+//
+
+import Foundation
+import Spezi
+@_spi(APISupport)
+import SpeziScheduler
+import SpeziSchedulerUI
+import SwiftUI
+
+
+struct ObserveNewOutcomesTestingView: View {
+    @Environment(Scheduler.self)
+    private var scheduler
+    
+    @EventQuery(in: Calendar.current.rangeOfDay(for: .now))
+    private var events
+    
+    @State private var observationToken: AnyObject?
+    @State private var didTrigger = false
+    
+    var body: some View {
+        Form {
+            Section {
+                LabeledContent("did trigger", value: didTrigger.description)
+            }
+            Section {
+                if let event = events.first(where: { $0.task.id == "TESTTESTTEST" }) {
+                    InstructionsTile(event) {
+                        _ = try? event.complete()
+                    } more: {
+                        EventDetailView(event)
+                    }
+                }
+            }
+        }
+        .onAppear {
+            guard observationToken == nil else {
+                return
+            }
+            observationToken = scheduler.observeNewOutcomes { outcome in
+                if outcome.task.id == "TESTTESTTEST" {
+                    didTrigger = true
+                }
+            }
path: Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
user: login: bauer-andreas
body: Instead of using this legacy observer based approach, couldn't we make use of structured concurrency altogether for this API and use the `.task` modifier and task cancellation to subscribe and unsubscribe? So the API surface would look like the following


```swift 
  .task {
    await scheduler.observeNewOutcomes { outcomes in
      for outcome in await outcomes {
        // do whatever you want.
      }
    }
  }
```

Of course we could make this `rethrows`. This would completely remove any of the subscription management complexity and fully embrace structured concurrency. The closure basically would get an async stream for the subscriptions and the overall subscription would be cancelled either when the task is cancelled (the view disappears) or the call returns because the subscriber is no longer interested in the subscription.
created_at: 2025-05-08T18:05:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -426,9 +431,9 @@ extension Scheduler {
     /// - Parameter taskId: The task id for which you want to delete all versions. Refer to ``Task/id``.
     public func deleteAllVersions(ofTask taskId: String) throws {
         let context = try context
-        try context.delete(model: Task.self, where: #Predicate { task in
-            task.id == taskId
-        })
+        for task in try context.fetch(FetchDescriptor(predicate: #Predicate<Task> { $0.id == taskId })) {
+            try context.delete(task)
path: Sources/SpeziScheduler/Scheduler.swift
user: login: lukaskollmer
body: i documented this in the commit message [here](https://github.com/StanfordSpezi/SpeziScheduler/commit/47c6d51236ee0c13bb897a16b6acc56095cb8449)

you're correct about the potential of reaching an inconsistent state; i'll look into maybe doing something about this
created_at: 2025-05-08T18:14:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,53 @@
+//
+// This source file is part of the Stanford Spezi open-source project
+//
+// SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
+//
+// SPDX-License-Identifier: MIT
+//
+
+import Foundation
+import Spezi
+@_spi(APISupport)
+import SpeziScheduler
+import SpeziSchedulerUI
+import SwiftUI
+
+
+struct ObserveNewOutcomesTestingView: View {
+    @Environment(Scheduler.self)
+    private var scheduler
+    
+    @EventQuery(in: Calendar.current.rangeOfDay(for: .now))
+    private var events
+    
+    @State private var observationToken: AnyObject?
+    @State private var didTrigger = false
+    
+    var body: some View {
+        Form {
+            Section {
+                LabeledContent("did trigger", value: didTrigger.description)
+            }
+            Section {
+                if let event = events.first(where: { $0.task.id == "TESTTESTTEST" }) {
+                    InstructionsTile(event) {
+                        _ = try? event.complete()
+                    } more: {
+                        EventDetailView(event)
+                    }
+                }
+            }
+        }
+        .onAppear {
+            guard observationToken == nil else {
+                return
+            }
+            observationToken = scheduler.observeNewOutcomes { outcome in
+                if outcome.task.id == "TESTTESTTEST" {
+                    didTrigger = true
+                }
+            }
path: Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
user: login: lukaskollmer
body: that was my initial thought as well, but using an AsyncStream doesn't work since `Outcome` isn't Sendable
created_at: 2025-05-08T18:19:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,53 @@
+//
+// This source file is part of the Stanford Spezi open-source project
+//
+// SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
+//
+// SPDX-License-Identifier: MIT
+//
+
+import Foundation
+import Spezi
+@_spi(APISupport)
+import SpeziScheduler
+import SpeziSchedulerUI
+import SwiftUI
+
+
+struct ObserveNewOutcomesTestingView: View {
+    @Environment(Scheduler.self)
+    private var scheduler
+    
+    @EventQuery(in: Calendar.current.rangeOfDay(for: .now))
+    private var events
+    
+    @State private var observationToken: AnyObject?
+    @State private var didTrigger = false
+    
+    var body: some View {
+        Form {
+            Section {
+                LabeledContent("did trigger", value: didTrigger.description)
+            }
+            Section {
+                if let event = events.first(where: { $0.task.id == "TESTTESTTEST" }) {
+                    InstructionsTile(event) {
+                        _ = try? event.complete()
+                    } more: {
+                        EventDetailView(event)
+                    }
+                }
+            }
+        }
+        .onAppear {
+            guard observationToken == nil else {
+                return
+            }
+            observationToken = scheduler.observeNewOutcomes { outcome in
+                if outcome.task.id == "TESTTESTTEST" {
+                    didTrigger = true
+                }
+            }
path: Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
user: login: bauer-andreas
body: But AsyncSequence is generally not required to have a Sendable element, right? Couldn’t we just build our own conformance that has a MainActor isolation (or ist just non-Sendable)?
created_at: 2025-05-08T19:11:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+//
+// This source file is part of the Stanford Spezi open-source project
+//
+// SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
+//
+// SPDX-License-Identifier: MIT
+//
+
+import Foundation
+import Spezi
+@_spi(APISupport)
+import SpeziScheduler
+import SpeziSchedulerUI
+import SwiftUI
+
+
+struct ObserveNewOutcomesTestingView: View {
+    @Environment(Scheduler.self)
+    private var scheduler
+    
+    @EventQuery(in: Calendar.current.rangeOfDay(for: .now))
+    private var events
+    
+    @State private var observationToken: AnyObject?
+    @State private var didTrigger = false
+    
+    var body: some View {
+        Form {
+            Section {
+                LabeledContent("did trigger", value: didTrigger.description)
+            }
+            Section {
+                if let event = events.first(where: { $0.task.id == "TESTTESTTEST" }) {
+                    InstructionsTile(event) {
+                        _ = try? event.complete()
+                    } more: {
+                        EventDetailView(event)
+                    }
+                }
+            }
+        }
+        .onAppear {
+            guard observationToken == nil else {
+                return
+            }
+            observationToken = scheduler.observeNewOutcomes { outcome in
+                if outcome.task.id == "TESTTESTTEST" {
+                    didTrigger = true
+                }
+            }
path: Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
user: login: bauer-andreas
body: See https://github.com/swiftlang/swift/blob/024dbd040fc11f2ad3f7ce017a703f7a2f756455/stdlib/public/Concurrency/AsyncSequence.swift#L75
created_at: 2025-05-08T19:12:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+//
+// This source file is part of the Stanford Spezi open-source project
+//
+// SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md)
+//
+// SPDX-License-Identifier: MIT
+//
+
+import Foundation
+import Spezi
+@_spi(APISupport)
+import SpeziScheduler
+import SpeziSchedulerUI
+import SwiftUI
+
+
+struct ObserveNewOutcomesTestingView: View {
+    @Environment(Scheduler.self)
+    private var scheduler
+    
+    @EventQuery(in: Calendar.current.rangeOfDay(for: .now))
+    private var events
+    
+    @State private var observationToken: AnyObject?
+    @State private var didTrigger = false
+    
+    var body: some View {
+        Form {
+            Section {
+                LabeledContent("did trigger", value: didTrigger.description)
+            }
+            Section {
+                if let event = events.first(where: { $0.task.id == "TESTTESTTEST" }) {
+                    InstructionsTile(event) {
+                        _ = try? event.complete()
+                    } more: {
+                        EventDetailView(event)
+                    }
+                }
+            }
+        }
+        .onAppear {
+            guard observationToken == nil else {
+                return
+            }
+            observationToken = scheduler.observeNewOutcomes { outcome in
+                if outcome.task.id == "TESTTESTTEST" {
+                    didTrigger = true
+                }
+            }
path: Tests/UITests/TestApp/ObserveNewOutcomesTestingView.swift
user: login: lukaskollmer
body: neither `AsyncSequence` nor `AsyncStream` constrain their `Element` type to be Sendable, but `AsyncStream.Continuation.yield(_:)` has a `sending` parameter, which doesn't work with our `Outcome` type
created_at: 2025-05-09T08:58:31Z
author_association: MEMBER
diff_hunk: @@ -677,6 +683,21 @@ extension Scheduler {
                 }
             }
     }
+    
+    /// Registers an observer function that gets called when new ``Outcome``s are created within the Scheduler.
+    ///
+    /// - parameter handler: A closure which will get invoked every time a new ``Outcome`` is added to the ``Scheduler``.
+    /// - returns: An opaque handle, to which the lifetime of the observation is tied.
+    @_spi(APISupport)
+    public func observeNewOutcomes(_ handler: @MainActor @escaping (Outcome) -> Void) -> AnyObject {
path: Sources/SpeziScheduler/Scheduler.swift
user: login: lukaskollmer
body: we'd need to spawn the Task in either case, since global-actor-isolated classes currently still have nonisolated `deinit`s. (with the exception of course of `UIView` and `UIViewController`, where UIKit internally guarantees that they are deallocated on the main thread.)

that being said, i really like the approach of having a dedicated type; i'll change the PR accordingly
created_at: 2025-05-09T09:15:26Z
author_association: MEMBER
diff_hunk: @@ -426,9 +431,9 @@ extension Scheduler {
     /// - Parameter taskId: The task id for which you want to delete all versions. Refer to ``Task/id``.
     public func deleteAllVersions(ofTask taskId: String) throws {
         let context = try context
-        try context.delete(model: Task.self, where: #Predicate { task in
-            task.id == taskId
-        })
+        for task in try context.fetch(FetchDescriptor(predicate: #Predicate<Task> { $0.id == taskId })) {
+            try context.delete(task)
path: Sources/SpeziScheduler/Scheduler.swift
user: login: lukaskollmer
body: update: as it turns out, `ModelContext.delete(_:)` doesn't even throw, so we don't need to worry about potential state inconsistencies here
created_at: 2025-05-09T09:40:24Z
author_association: MEMBER
diff_hunk: @@ -12,11 +12,14 @@ import UserNotifications
 
 extension Schedule {
     enum NotificationMatchingHint: Codable, Sendable, Hashable {
+        case none
path: Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift
user: login: lukaskollmer
body: we can safely assume that there are no `nil` values stored for the `notificationMatchingHint` property (since that's what's causing the crashes, so any app that doesn't crash also doesn't have `nil` values).

i did test opening an existing data store (created w/ a pre-this-PR version of the Scheduler) with the new types, and it worked fine.

comparing the old and new schema, SwiftData does change the type of the `notificationMatchingHint` property in the `Task` entity from `Optional<NotificationMatchingHint>` to just `NotificationMatchingHint` (as we'd expect).
created_at: 2025-05-09T10:03:32Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-05-07T18:50:24Z
body: ## [Codecov](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi) Report
Attention: Patch coverage is `72.72727%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 68.52%. Comparing base [(`692258c`)](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/commit/692258cf0c4662f30e2d0db99f1504069647f0b3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi) to head [(`abc6fd7`)](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/commit/abc6fd7a89767d1dace7c1d56c9c12300489edc8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi) | Patch % | Lines |
|---|---|---|
| [Sources/SpeziScheduler/Scheduler.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziScheduler%2FScheduler.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlci9TY2hlZHVsZXIuc3dpZnQ=) | 66.67% | [2 Missing :warning: ](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi) |
| [...heduler/Notifications/SchedulerNotifications.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziScheduler%2FNotifications%2FSchedulerNotifications.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlci9Ob3RpZmljYXRpb25zL1NjaGVkdWxlck5vdGlmaWNhdGlvbnMuc3dpZnQ=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi) |

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70/graphs/tree.svg?width=650&height=150&src=pr&token=0VQNE0E1Wp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi)](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi)

```diff
@@            Coverage Diff             @@
##             main      #70      +/-   ##
==========================================
+ Coverage   67.59%   68.52%   +0.93%     
==========================================
  Files          41       40       -1     
  Lines         472      470       -2     
==========================================
+ Hits          319      322       +3     
+ Misses        153      148       -5     
```

| [Files with missing lines](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi) | Coverage Δ | |
|---|---|---|
| [...heduler/Notifications/Schedule+Notifications.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziScheduler%2FNotifications%2FSchedule%2BNotifications.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlci9Ob3RpZmljYXRpb25zL1NjaGVkdWxlK05vdGlmaWNhdGlvbnMuc3dpZnQ=) | `66.67% <100.00%> (ø)` | |
| [Sources/SpeziScheduler/Schedule/Schedule.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziScheduler%2FSchedule%2FSchedule.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlci9TY2hlZHVsZS9TY2hlZHVsZS5zd2lmdA==) | `66.67% <100.00%> (+0.67%)` | :arrow_up: |
| [Sources/SpeziSchedulerUI/EventActionButton.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziSchedulerUI%2FEventActionButton.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlclVJL0V2ZW50QWN0aW9uQnV0dG9uLnN3aWZ0) | `75.00% <100.00%> (ø)` | |
| [...heduler/Notifications/SchedulerNotifications.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziScheduler%2FNotifications%2FSchedulerNotifications.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlci9Ob3RpZmljYXRpb25zL1NjaGVkdWxlck5vdGlmaWNhdGlvbnMuc3dpZnQ=) | `69.50% <0.00%> (ø)` | |
| [Sources/SpeziScheduler/Scheduler.swift](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?src=pr&el=tree&filepath=Sources%2FSpeziScheduler%2FScheduler.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi#diff-U291cmNlcy9TcGV6aVNjaGVkdWxlci9TY2hlZHVsZXIuc3dpZnQ=) | `79.37% <66.67%> (-1.66%)` | :arrow_down: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi)

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi). Last update [692258c...abc6fd7](https://app.codecov.io/gh/StanfordSpezi/SpeziScheduler/pull/70?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StanfordSpezi).

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: lukaskollmer
created_at: 2025-05-07T20:47:22Z
body: @Supereg would really appreciate your input on the changes in [this commit](https://github.com/StanfordSpezi/SpeziScheduler/pull/70/commits/e995db36fa72527d608d3f9a41696520de8213e6). i'm pretty sure that this is correct, but you probably have a better understanding of the Scheduler implementation and API as a whole than i have.
author_association: MEMBER