Jul 27 - Update code for scheduled tasks 4

This commit is contained in:
Nguyen Ngo
2026-07-27 16:43:42 -04:00
parent 4132373fee
commit 9926739cb6
9 changed files with 162 additions and 49 deletions
+4
View File
@@ -698,6 +698,10 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
| 69 | **`ExecuteInspectionView.resolveAndFulfillSchedule()` links the submission to its schedule and invalidates the cached row — it never computes the next due date** | Two entry points reach the identical form (Scheduled row → prefilled; `+` → hand-picked), but only the first sets `scheduledInspectionServerId`, so a `+`-started inspection lands with `scheduled_inspection_id = NULL` and the schedule stays **Active** on the web. The fallback matches an active `LocalScheduledInspection` on facility + template, gated to `dueDateString <= today`, assignee `nil`-or-self, earliest due first, and skipped for re-inspections. Roll-forward stays server-side: the local model has no phase43 recurrence detail (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the row is **flagged `fulfilledLocally`, never deleted** (see rule 71), and `pullScheduledInspections()` writes the authoritative `next_due_date` via `update(from:)` — which also self-heals a submission that never lands. |
| 70 | **Snapshot schedule instructions into `@State` in `onAppear` — never read them from SwiftData during `body`** | `ExecuteInspectionView` shows the schedule's instructions above the form, but `resolveAndFulfillSchedule()` **deletes** that `LocalScheduledInspection` the instant Submit is tapped and the view stays up for another 2.5 s showing the success banner. A computed lookup would re-read a deleted `PersistentModel` in that window and trap. `loadScheduleInstructions()` copies the `String` once, at appear. Same reasoning as `ScheduledStartTarget` (rule 68): once the fulfilment path can delete a cached row mid-flow, every consumer must hold a value, not the model. |
| 71 | **Never delete a cached row the server is going to send again — flag it** | The first cut of `resolveAndFulfillSchedule()` deleted the `LocalScheduledInspection` at submit. One-time schedules were fine (the server deactivates them and never returns them again), but every **recurring** schedule is returned again on its next occurrence, so each completion became delete-then-reinsert against an `@Attribute(.unique)` serverId — and the reinserted row did not reliably carry the rolled-forward date. A daily schedule kept showing today's date after being completed. Fix: `fulfilledLocally: Bool = false` hides the row locally; `init(from:)`/`update(from:)` clear it, so the pull remains the only thing that ever writes a cached schedule's dates. Deletion of schedule rows now happens in exactly one place — the "server no longer returns it" branch of `pullScheduledInspections()`. |
| 72 | **A toolbar `Label` needs `.labelStyle(.titleAndIcon)` or SwiftUI renders it icon-only** | `IssuesView`'s New Issue button was written as `Label("New Issue", systemImage: "plus")` and still appeared on the iPad as a bare "+" — SwiftUI decides toolbar label styling itself and drops the title. Having the text in the source is not enough; state the style explicitly. Both creation entry points (`MyInspectionsView` → New Inspection, `IssuesView` → New Issue) now pin `.titleAndIcon` alongside `.borderedProminent`. |
| 73 | **Both background-sync Info.plist keys must live in `Info.plist` itself — `INFOPLIST_KEY_*` cannot express them** | `BGTaskSchedulerPermittedIdentifiers` and `UIBackgroundModes` are both **arrays**. `INFOPLIST_KEY_*` build settings only merge Xcode's recognised key list and only as **strings**, so `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync` never produced a valid entry — it sat inert in the pbxproj while `BGTaskScheduler.submit()` failed with `.notPermitted` under a `try?`. Adding `UIBackgroundModes` then made App Store Connect check, and the upload was rejected with **error 90771**. Both keys now live in `JanitorialQC/Info.plist` as arrays and the build setting is deleted from both configurations. Do not reintroduce it: a build setting overwrites the file's value at merge time. Keep the identifier string in sync with `BGTaskScheduler.register` / `BGProcessingTaskRequest` in `JanitorialQCApp`. Verify a build before uploading: `plutil -p <built .app>/Info.plist \| grep -A2 BGTask` must show an array. |
| 74 | **Two different background mechanisms — do not confuse them** | `BGProcessingTask` (JanitorialQCApp) asks iOS to **wake us later**: opportunistic, typically charging + Wi-Fi + idle, not a heartbeat. `beginBackgroundTask` (`SyncManager.beginSyncBackgroundTask`) asks iOS **not to suspend us right now**: ~30 s, covers submit-then-lock. The expiration handler must end the assertion or iOS terminates the app, and it needs `MainActor.assumeIsolated` because the closure is nonisolated under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. |
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
---
+21 -1
View File
@@ -1,5 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<!-- Background sync. BOTH keys are required and BOTH must live in this
file, not in build settings.
INFOPLIST_KEY_* only merges Xcode's recognised key list and it merges
STRING values. BGTaskSchedulerPermittedIdentifiers must be an ARRAY, so
the old INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers build setting
never produced a valid entry — App Store Connect rejected the upload
with error 90771 as soon as UIBackgroundModes made the validator look.
That build setting has been removed from project.pbxproj; do not put it
back. Keep the identifier below in sync with the string passed to
BGTaskScheduler.register/BGProcessingTaskRequest in JanitorialQCApp. -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.jqc.sync</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
</array>
</dict>
</plist>
+42 -3
View File
@@ -128,7 +128,11 @@ struct JanitorialQCApp: App {
}
private func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
// register() returns false when the identifier is not declared in
// BGTaskSchedulerPermittedIdentifiers or the `processing` background
// mode is missing. Both are easy to lose in a project settings change
// and the failure is otherwise completely silent, so it is logged.
let registered = BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.jqc.sync",
using: nil
) { task in
@@ -138,16 +142,44 @@ struct JanitorialQCApp: App {
}
handleBackgroundSync(task: processingTask)
}
if !registered {
print("[JQC] BGTaskScheduler.register FAILED for com.jqc.sync — "
+ "check UIBackgroundModes contains 'processing' and "
+ "BGTaskSchedulerPermittedIdentifiers contains com.jqc.sync")
}
}
private func handleBackgroundSync(task: BGProcessingTask) {
// Re-arm first: if anything below throws or the task is killed, a
// request is already queued for the next opportunity.
scheduleBackgroundSync()
let syncTask = Task {
let syncTask = Task { @MainActor in
// A BGTaskScheduler launch does not render ContentView, so the
// `.task { restoreSession() }` there never runs and
// AuthManager.isAuthenticated is still false. triggerSync() guards
// on it and would return having done nothing at all.
if !AuthManager.shared.isAuthenticated {
await AuthManager.shared.restoreSession()
}
// The SwiftData container is created by the `.modelContainer`
// scene modifier, so on a COLD background launch (process was
// terminated, no scene connected) there is no context to drain.
// The common case app suspended but still resident has one.
guard SyncManager.shared.modelContext != nil else {
print("[JQC] background sync skipped — no model context "
+ "(cold launch, no scene)")
return
}
await SyncManager.shared.triggerSync()
}
task.expirationHandler = {
syncTask.cancel()
}
Task {
await syncTask.value
task.setTaskCompleted(success: !syncTask.isCancelled)
@@ -178,5 +210,12 @@ func scheduleBackgroundSync() {
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(request)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
// Was `try?`. Submitting without the `processing` background mode fails
// with BGTaskSchedulerError.notPermitted, which is exactly how this
// whole path stayed dead unnoticed. Never swallow it again.
print("[JQC] BGTaskScheduler.submit failed: \(error)")
}
}
-42
View File
@@ -1,42 +0,0 @@
JQC iOS — recurring schedules keep showing the old due date
===========================================================
Repo: jqc_ios_app iOS ONLY — no server change, no migration.
OVERWRITE (paths relative to the folder containing JanitorialQC.xcodeproj):
JanitorialQC/Models/LocalScheduledInspection.swift
JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift
JanitorialQC/Views/Dashboard/MyInspectionsView.swift
JanitorialQC/CLAUDE.md
No new files, none removed, no .xcodeproj edit.
SwiftData: `fulfilledLocally: Bool = false` is a new stored property, but it is
non-optional WITH an inline default -> lightweight migration. Existing rows read
as false. NO need to delete the app from the iPad.
BUILD
Product > Clean Build Folder (Shift-Cmd-K), then run.
VERIFY
1. DAILY schedule due today. Complete + submit on the iPad.
- SCHEDULED card clears immediately
- within one sync cycle (<=60s) the row REAPPEARS showing TOMORROW
Cross-check the server agrees:
SELECT id, frequency, next_due_date, last_completed_at
FROM scheduled_inspections WHERE id = <id>;
2. WEEKLY Mon/Wed/Fri: complete on Monday -> row returns showing Wednesday.
3. MONTHLY day-of-month: complete -> row returns showing next month.
4. ONE-TIME: complete -> row clears and STAYS gone (server deactivates it).
This is the case that always worked and must not regress.
5. Offline: airplane mode, complete a daily schedule
-> card clears at once, row stays hidden while offline
-> re-enable network -> row returns with tomorrow's date
6. Failed submit: if the inspection never syncs, the row comes back with its
ORIGINAL date (still genuinely due) rather than staying hidden.
IF STEP 1 STILL FAILS
Check which side is wrong before changing the app again:
- server shows tomorrow, iPad shows today -> client, reopen this fix
- server still shows today -> fulfill() never ran; check
sudo journalctl -u janitorial_qc | grep "schedule fulfilled"
+40
View File
@@ -10,6 +10,7 @@ import SwiftData
import SwiftUI
import Combine
import UserNotifications
import UIKit // beginBackgroundTask see beginSyncBackgroundTask()
@MainActor
class SyncManager: ObservableObject {
@@ -110,6 +111,36 @@ class SyncManager: ObservableObject {
pollTask = nil
}
// Background task assertion
// Keeps the process alive across a suspend so an in-flight sync can finish.
// Distinct from the BGProcessingTask in JanitorialQCApp: that one asks iOS
// to WAKE us later, this one asks it not to suspend us right now.
private var syncBackgroundTaskId: UIBackgroundTaskIdentifier = .invalid
private func beginSyncBackgroundTask() {
// triggerSync() is re-entrancy guarded, but assert defensively anyway:
// beginning a second assertion would leak the first identifier.
guard syncBackgroundTaskId == .invalid else { return }
syncBackgroundTaskId = UIApplication.shared.beginBackgroundTask(
withName: "JQC.syncDrain"
) {
// Called on the main thread when the grace period runs out. It MUST
// end the assertion or iOS terminates the app. assumeIsolated is
// required because the handler is a nonisolated closure under
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
MainActor.assumeIsolated {
SyncManager.shared.endSyncBackgroundTask()
}
}
}
private func endSyncBackgroundTask() {
guard syncBackgroundTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(syncBackgroundTaskId)
syncBackgroundTaskId = .invalid
}
/// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() {
lastNotificationFetch = nil
@@ -232,6 +263,15 @@ class SyncManager: ObservableObject {
syncError = nil
defer { isSyncing = false }
// Ask iOS to keep the process alive long enough to finish the drain.
// The case this covers: an inspector taps Submit and immediately locks
// the iPad or swipes to another app. Without an assertion the process
// suspends mid-upload and the work waits for the next launch.
// Roughly 30 s of grace; the expiration handler ends it cleanly so iOS
// never force-kills us. Harmless in the foreground it simply ends.
beginSyncBackgroundTask()
defer { endSyncBackgroundTask() }
await processPhotoQueue(context: context)
await processInspectionQueue(context: context)
await processIssueQueue(context: context)
@@ -145,11 +145,15 @@ struct IssuesListView: View {
// New Issue borderedProminent so it stands out clearly
// from the filter icon and is easy to find at a glance.
// `.titleAndIcon` is required: without it SwiftUI collapses the
// Label to icon-only in a toolbar, so this rendered as a bare
// "+" despite having a title in code.
Button {
showNewIssue = true
} label: {
Label("New Issue", systemImage: "plus")
}
.labelStyle(.titleAndIcon)
.buttonStyle(.borderedProminent)
}
}
@@ -101,9 +101,14 @@ struct MyInspectionsView: View {
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
// Labelled, not a bare "+". `.titleAndIcon` is required:
// SwiftUI collapses a toolbar Label to icon-only on its own,
// which is what made this read as an unlabelled plus sign.
Button { showNewInspection = true } label: {
Image(systemName: "plus")
Label("New Inspection", systemImage: "plus")
}
.labelStyle(.titleAndIcon)
.buttonStyle(.borderedProminent)
}
}
.fullScreenCover(isPresented: $showNewInspection) {