StanfordSpezi / SpeziScheduler

Scheduler Module for the Stanford Spezi Ecosystem
https://swiftpackageindex.com/StanfordSpezi/SpeziScheduler/documentation/
MIT License
3 stars 3 forks source link

Bug report: Survey view sometimes not refreshing after opening notification or completing survey #12

Closed mjoerke closed 1 year ago

mjoerke commented 1 year ago

Description

Our application uses the SpeziScheduler module to send several survey notifications per day at randomized intervals.

Occasionally, when a user clicks on a notification, the survey that has just become eligible does not appear in the "Survey" tab in the application (i.e., the view has not been updated). After switching tabs or closing and re-opening the application, the new survey appears.

Similarly, after a user completes a survey, the survey is occasionally not marked as completed in the survey view, and the user can repeat the survey again.

Reproduction

The behavior is non-deterministic and unfortunately cannot always reliably be reproduced.

Expected behavior

The survey view should always be up-to-date -- showing all surveys that are eligible and marking completed surveys as complete -- whenever the view is loaded.

Additional context

Here is the code we use for scheduling notifications:

import Foundation
import SpeziFHIR
import SpeziScheduler

/// A `Scheduler` using the `FHIR` standard as well as the ``HPDSTaskContext`` to schedule and manage tasks and events in the
/// Spezi Template Applciation.
typealias HPDSScheduler = Scheduler<FHIR, HPDSTaskContext>

extension HPDSScheduler {
    /// Creates a default instance of the ``HPDSScheduler`` by scheduling the tasks listed below.
    convenience init() {
        self.init(
            tasks: [
                Task(
                    title: String(localized: "MORNING_SURVEY_TITLE"),
                    description: String(localized: "MORNING_SURVEY_DESCRIPTION"),
                    schedule: Schedule(
                        start: Calendar.current.startOfDay(for: Date()),
                        repetition: .randomBetween(start: .init(hour: 8, minute: 00), end: .init(hour: 11, minute: 00)), // Every Day between 8-11 AM
                        end: .numberOfEvents(356)
                    ),
                    notifications: true,
                    context: HPDSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "morning-en-US"))
                ),
                Task(
                    title: String(localized: "MID_DAY_SURVEY_TITLE"),
                    description: String(localized: "MID_DAY_SURVEY_DESCRIPTION"),
                    schedule: Schedule(
                        start: Calendar.current.startOfDay(for: Date()),
                        repetition: .randomBetween(start: .init(hour: 11, minute: 00), end: .init(hour: 14, minute: 00)), // Every Day at 11-14 PM
                        end: .numberOfEvents(356)
                    ),
                    notifications: true,
                    context: HPDSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "mid-day-en-US"))
                ),
                Task(
                    title: String(localized: "AFTERNOON_SURVEY_TITLE"),
                    description: String(localized: "AFTERNOON_SURVEY_DESCRIPTION"),
                    schedule: Schedule(
                        start: Calendar.current.startOfDay(for: Date()),
                        repetition: .randomBetween(start: .init(hour: 14, minute: 00), end: .init(hour: 17, minute: 00)), // Every Day at 14-17 PM
                        end: .numberOfEvents(356)
                    ),
                    notifications: true,
                    context: HPDSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "afternoon-en-US"))
                ),
                Task(
                    title: String(localized: "END_OF_THE_DAY_SURVEY_TITLE"),
                    description: String(localized: "END_OF_THE_DAY_SURVEY_DESCRIPTION"),
                    schedule: Schedule(
                        start: Calendar.current.startOfDay(for: Date()),
                        repetition: .matching(.init(hour: 17, minute: 00)), // Every Day at 17:00 PM
                        end: .numberOfEvents(356)
                    ),
                    notifications: true,
                    context: HPDSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "end-of-day-en-US"))
                )
            ]
        )
    }
}

and here is our ScheduleView.swift, only with minor aesthetic changes from the SpeziTemplateApplication in the body

import SpeziQuestionnaire
import SpeziScheduler
import SwiftUI

struct ScheduleView: View {
    @EnvironmentObject var scheduler: HPDSScheduler
    @State var eventContextsByDate: [Date: [EventContext]] = [:]
    @State var presentedContext: EventContext?

    var startOfDays: [Date] {
        Array(eventContextsByDate.keys)
    }

    var body: some View {
        NavigationStack {
            List(startOfDays, id: \.timeIntervalSinceNow) { startOfDay in
                ForEach(eventContextsByDate[startOfDay] ?? [], id: \.event) { eventContext in
                    Section {
                        EventContextView(eventContext: eventContext)
                            .onTapGesture {
                                if !eventContext.event.complete {
                                    presentedContext = eventContext
                                }
                            }
                    }
                }
            }
                .onReceive(scheduler.objectWillChange) { _ in
                    calculateEventContextsByDate()
                }
                .task {
                    calculateEventContextsByDate()
                }
                .sheet(item: $presentedContext) { presentedContext in
                    destination(withContext: presentedContext)
                }
                .navigationTitle("SCHEDULE_LIST_TITLE")
        }
    }

    private func destination(withContext eventContext: EventContext) -> some View {
        @ViewBuilder
        var destination: some View {
            switch eventContext.task.context {
            case let .questionnaire(questionnaire):
                QuestionnaireView(questionnaire: questionnaire) { _ in
                    _Concurrency.Task {
                        await eventContext.event.complete(true)
                    }
                }
            }
        }
        return destination
    }

    private func format(startOfDay: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .long
        dateFormatter.timeStyle = .none
        return dateFormatter.string(from: startOfDay)
    }

    private func calculateEventContextsByDate() {
        // Adjust the following parameters to your needs of the application:

        // Display start date.
        // We just display all tasks that have been scheduled for today
        let startDate = Calendar.current.startOfDay(for: .now)

        // Display end cutoff. Can be either a number of events or a date.
        // In this case, we display a maximum of 100 events up until the current point in time.
        let eventCutoffDate = Date.now.addingTimeInterval(1)
        let eventCutoff = Schedule.End.numberOfEventsOrEndDate(100, eventCutoffDate)

        let eventContexts = scheduler.tasks.flatMap { task in
            task
                .events(
                    from: startDate,
                    to: eventCutoff
                )
                .map { event in
                    EventContext(event: event, task: task)
                }
        }
            .sorted()

        let newEventContextsByDate = Dictionary(grouping: eventContexts) { eventContext in
            Calendar.current.startOfDay(for: eventContext.event.scheduledAt)
        }

        if newEventContextsByDate != eventContextsByDate {
            eventContextsByDate = newEventContextsByDate
        }
    }
}

Code of Conduct