diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 0dcaf85..7b1970f 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -432,7 +432,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -476,7 +476,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index c74b09c..937faec 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -389,6 +389,12 @@ Without it — the original bug — the schedule was never fulfilled: the banner No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the section clears on the same sync that submits the inspection. +**Second fix (July 2026) — the `+` path and the vanishing cover.** The above only covers inspections *started from a Scheduled row*. The iPad's `+` button reaches the identical form with the facility and template hand-picked, and that path leaves `scheduledInspectionServerId` nil — the server stores `scheduled_inspection_id = NULL`, never calls `_fulfill_schedule()`, and the schedule stays **Active** on the web. On the web this cannot happen: `scheduled_inspections.start` is the only way in. `ExecuteInspectionView.resolveAndFulfillSchedule()` (called from `submitInspection()`, before `context.save()`) restores parity — see rule 69 for the match criteria and why the cached row is deleted rather than rolled forward. + +Deleting that row at submit time exposed a second problem: `ScheduledInspectionsCard` self-hid the moment its `@Query` emptied, tearing down the `.fullScreenCover` it owned — with the inspector's form inside it. Cover ownership therefore moved to the parents (`DashboardStatsView`'s `ScrollView`, `MyInspectionsView`'s `Group`) and the cover item became the value type `ScheduledStartTarget`. See rule 68. + +**Instructions (July 2026).** The manager-authored text on a schedule is labelled **"Instructions"** everywhere the user sees it, but remains `notes` on the wire, in `APIScheduledInspection`, and in `LocalScheduledInspection`. `LocalScheduledInspection.instructions` is a *computed* accessor over `notes` that trims and nils-out blank text — no stored property, so no schema change and no migration. Three surfaces: a one-line preview on `ScheduledRow`, a full Section at the top of `StartInspectionView` (passed in as `preFillScheduleInstructions` via `ScheduledStartTarget.instructions`), and a collapsible banner above the form in `ExecuteInspectionView` (looked up from SwiftData, so it also works on the draft-resume path where no parameter is threaded). See rule 70. + ### Inspection-start presentation (July 2026) All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button. @@ -682,10 +688,13 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 62 | **Sort `schema` by `(row, col)` before grouping fields into row buckets** | The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by `row` and attaches section headers must first sort by `(f["row"], f["col"])` — exactly like the web's `sorted(key=lambda f: (f['row'], f['col']))`. Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute `(col, row)` pixel offsets (e.g. `GridFormView` ZStack, `canvasHeight()`) are unaffected — sort order only matters when grouping by row for sequential rendering. | | 63 | **`@Attribute(.unique)` must NOT carry an inline default value** | A `.unique` key with a default (`@Attribute(.unique) var serverId: Int = 0`) stops the `@Model` macro from emitting a clean `PersistentModel` conformance. Symptom is misleading: the `.modelContainer(for: [ … ])` array literal fails to type-check and Xcode reports **"Cannot find '' in scope" on the *other* schema elements**, not the offending one. Declare the unique key with no default (`@Attribute(.unique) var serverId: Int`) and set it in `init`, exactly like `LocalFacility`/`LocalArea`. (Bit us adding `LocalScheduledInspection`, July 2026.) | -| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. | +| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack`/`Group` root instead — and that root must also **outlive the data that drives the presentation** (see rule 68). The dashboard scheduled cover lives on `DashboardStatsView`'s `ScrollView`; `MyInspectionsView` puts the scheduled "Start" cover on its enclosing `Group`. | | 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). | | 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. | | 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. | +| 68 | **A `.fullScreenCover` owner must outlive the rows that trigger it; carry a value snapshot, not the `@Model` object** | `ScheduledInspectionsCard` self-hides on `scheduled.isEmpty`, and submitting the last scheduled inspection empties that `@Query` **while the cover is still on screen** — the card disappears and takes its cover (and the inspector's form) with it. Fix: the card is presentational and reports taps via `onStart`; `DashboardStatsView` owns the cover on its always-present `ScrollView`, `MyInspectionsView` on its `Group`. The cover item is `ScheduledStartTarget` (three plain `Int`s), never `LocalScheduledInspection` — reading a deleted `PersistentModel` traps. | +| 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 **deleted**, and `pullScheduledInspections()` re-inserts it with the authoritative `next_due_date` — 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. | --- diff --git a/JanitorialQC/DEPLOYMENT_MAP.txt b/JanitorialQC/DEPLOYMENT_MAP.txt new file mode 100644 index 0000000..8e950d5 --- /dev/null +++ b/JanitorialQC/DEPLOYMENT_MAP.txt @@ -0,0 +1,61 @@ +JQC — "Instructions" on scheduled inspections +============================================= +NO migration. NO schema change (web or iOS). NO API change. +The field stays `notes` end-to-end: WTForms field name, ScheduledInspection.notes, +scheduled_inspections.notes column, and the JSON key in +GET /api/v1/scheduled-inspections. Only the wording changed. + +-------------------------------------------------------------------- +WEB — repo: lt_janitorial_quality_control + deploy root: /home/jqc/janitorial_qc/ +-------------------------------------------------------------------- +Overwrite: + app/utils/forms.py (label 'Notes' -> 'Instructions') + app/templates/scheduled_inspections/form.html (placeholder + visibility hint, rows 2->3) + app/templates/inspections/execute.html (NEW: instructions panel) + CLAUDE.md + +Deploy (code only — no alembic step): + cd /home/jqc/janitorial_qc + git pull + sudo systemctl restart janitorial_qc + sudo systemctl status janitorial_qc --no-pager + +-------------------------------------------------------------------- +iOS — repo: jqc_ios_app +-------------------------------------------------------------------- +Overwrite: + JanitorialQC/Models/LocalScheduledInspection.swift (computed `instructions`) + JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift + JanitorialQC/Views/Dashboard/StartInspectionView.swift + JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift + JanitorialQC/Views/Dashboard/MyInspectionsView.swift + JanitorialQC/Views/Dashboard/DashboardView.swift + JanitorialQC/CLAUDE.md + +No new files, none removed, no .xcodeproj edit +(PBXFileSystemSynchronizedRootGroup picks changes up automatically). +`instructions` is COMPUTED, not stored -> no SwiftData migration, +no need to delete the app from the iPad. + +Build: Product > Clean Build Folder (Shift-Cmd-K), then run. + +-------------------------------------------------------------------- +VERIFY +-------------------------------------------------------------------- +1. Web > Inspections > Scheduled > New Schedule + - field reads "Instructions", hint below it + - save some text +2. Web, as the assigned inspector: Start that schedule + - indigo "Instructions for this inspection" panel between header and form +3. iPad, after a sync: + - Dashboard SCHEDULED card: 1-line preview with an info icon + - tap the row: "Instructions" section at the top of the start screen + - Start Inspection: collapsible "Instructions" banner above the form, + tap the header to collapse/expand +4. Draft-resume regression: back out mid-inspection, reopen from the blue + "Inspection in progress" banner -> instructions still shown +5. Empty case: a schedule with no instructions shows NOTHING extra anywhere + (blank and whitespace-only both normalise to nil) +6. Submit regression: banner stays readable through the 2.5s success screen + and the app does not crash when the schedule row is deleted diff --git a/JanitorialQC/Models/LocalScheduledInspection.swift b/JanitorialQC/Models/LocalScheduledInspection.swift index fd5ecf2..b6b5b50 100644 --- a/JanitorialQC/Models/LocalScheduledInspection.swift +++ b/JanitorialQC/Models/LocalScheduledInspection.swift @@ -38,6 +38,24 @@ final class LocalScheduledInspection { var isOverdue: Bool var notes: String? + /// Manager-authored instructions for this occurrence, normalised. + /// + /// The wire field, the server model attribute and the DB column are all + /// still `notes` — only the user-facing wording changed to "Instructions" + /// (web form label + both iPad surfaces). Renaming the stored property would + /// break `init(from:)`/`update(from:)` against `APIScheduledInspection.notes` + /// for no benefit, so the rename lives here. + /// + /// Returns nil for nil, empty, or whitespace-only text so callers can guard + /// with a single `if let` instead of repeating the emptiness check. + /// Computed, not stored — no SwiftData schema change. + var instructions: String? { + guard let t = notes?.trimmingCharacters(in: .whitespacesAndNewlines), + !t.isEmpty + else { return nil } + return t + } + /// Last time this row was refreshed from the server pull. var updatedAt: Date diff --git a/JanitorialQC/PLACEMENT_MAP.txt b/JanitorialQC/PLACEMENT_MAP.txt new file mode 100644 index 0000000..c4c6b3b --- /dev/null +++ b/JanitorialQC/PLACEMENT_MAP.txt @@ -0,0 +1,44 @@ +JQC iOS — scheduled inspection fulfilment fix +============================================= +Repo: jqc_ios_app Branch target: main +All paths are relative to the repo root (the folder containing JanitorialQC.xcodeproj). + +DROP-IN (overwrite): + JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift + JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift + JanitorialQC/Views/Dashboard/MyInspectionsView.swift + JanitorialQC/Views/Dashboard/DashboardView.swift + JanitorialQC/CLAUDE.md + +NO new files. NO files removed. NO Xcode project edits +(the target uses PBXFileSystemSynchronizedRootGroup — new/changed +files under JanitorialQC/ are picked up automatically). + +NO server changes. NO SwiftData schema change, so no migration +and no need to delete the app from the iPad. + +BUILD + 1. git pull / copy files in + 2. Xcode → Product → Clean Build Folder (Shift-Cmd-K) + 3. Build & run on the iPad + +VERIFY + A. Scheduled row path + Dashboard → SCHEDULED → tap row → Start → complete → Submit + → SCHEDULED card clears immediately + → web: Inspections → Scheduled → one-time schedule shows "Inactive" + B. "+" path (this is the case that was broken) + Dashboard/My Inspections → "+" → pick the SAME facility + template + as a schedule due today or earlier → complete → Submit + → same result as A + C. Offline + Airplane mode → run A → SCHEDULED card clears at once + → re-enable network → schedule goes Inactive on the web + D. Regression + Re-inspection (History → Re-inspect) must NOT deactivate any schedule + Ad-hoc inspection for a facility whose schedule is due NEXT month + must NOT deactivate it + +SERVER-SIDE CHECK + sudo journalctl -u janitorial_qc --since "10 min ago" | grep "API INSPECTIONS" + expect: API INSPECTIONS | schedule fulfilled | schedule=N | inspection=M | next=deactivated diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index daf8cea..9eb8d04 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -266,6 +266,11 @@ struct DashboardStatsView: View { ) private var draftInspections: [LocalInspection] @Environment(\.modelContext) private var context + /// Schedule the inspector tapped in ScheduledInspectionsCard. Held here, not + /// in the card: the card self-hides, and submitting the last scheduled + /// inspection empties its @Query while the start form is still presented. + @State private var scheduledStartTarget: ScheduledStartTarget? = nil + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -279,7 +284,9 @@ struct DashboardStatsView: View { // Planned/recurring assignments for this inspector. Self-hides // when there are none. Tap a row to start it (facility + // template preselected). - ScheduledInspectionsCard() + ScheduledInspectionsCard(onStart: { target in + scheduledStartTarget = target + }) if let stats = sync.dashboardStats { // ── Today ────────────────────────────────────────────── @@ -407,6 +414,19 @@ struct DashboardStatsView: View { .refreshable { await sync.fetchDashboardStats() } + // Start cover for a tapped scheduled inspection. Owned here rather than + // by ScheduledInspectionsCard because that card self-hides the instant + // its last row is removed — which is exactly when this cover is on + // screen (submit deletes the cached schedule row). The ScrollView is + // always present, so the form is never torn down mid-submit. + .fullScreenCover(item: $scheduledStartTarget) { t in + StartInspectionView( + preFillTemplateId: t.templateServerId, + preFillFacilityId: t.facilityServerId, + preFillScheduleId: t.id, + preFillScheduleInstructions: t.instructions + ) + } } // ── Helpers ──────────────────────────────────────────────────────────── diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 085c0fd..79de01a 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -40,6 +40,14 @@ struct ExecuteInspectionView: View { @State private var isSubmitting = false @State private var submitResult: SubmitResult? + // ── Schedule instructions ───────────────────────────────────────────── + // Snapshotted into @State in onAppear rather than read from SwiftData on + // every body pass: resolveAndFulfillSchedule() DELETES the cached + // LocalScheduledInspection row at submit time, while this view is still on + // screen showing the success banner. Reading a deleted PersistentModel traps. + @State private var scheduleInstructions: String? = nil + @State private var instructionsExpanded = true + // Location manager — created on view init. requestLocation() is called in // onAppear so the permission prompt (and GPS fix acquisition) starts as // soon as the inspector opens the inspection, maximising the chance of @@ -158,6 +166,7 @@ struct ExecuteInspectionView: View { } .onAppear { formValues = inspection.formData.compactMapValues { "\($0)" } + loadScheduleInstructions() // Request Location permission (and start acquiring a fix) the moment // the inspector opens the inspection — gives GPS the entire duration // of the inspection to get a fix, rather than only the few seconds @@ -260,6 +269,15 @@ struct ExecuteInspectionView: View { headerCard .padding(.bottom, 16) + // Instructions from the schedule this inspection fulfils. Sits directly + // above the form so the inspector can re-read it mid-inspection without + // leaving the screen. Collapsible because long instructions would + // otherwise push the first form field below the fold. + if let instructions = scheduleInstructions { + instructionsBanner(instructions) + .padding(.bottom, 16) + } + // CHANGED: grid-based form rendering (replaces card-grouped linear list) if formSchema.isEmpty { emptyFormPlaceholder @@ -297,6 +315,60 @@ struct ExecuteInspectionView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } + // ── Instructions Banner ─────────────────────────────────────────────── + + /// Manager-authored instructions for the schedule this inspection fulfils. + /// Wire/DB field is `notes`; "Instructions" is the user-facing wording. + private func instructionsBanner(_ text: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + instructionsExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue) + Text("Instructions") + .font(.callout.bold()) + .foregroundStyle(.blue) + Spacer() + Image(systemName: instructionsExpanded ? "chevron.up" : "chevron.down") + .font(.caption.bold()) + .foregroundStyle(.blue) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if instructionsExpanded { + Text(text) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.blue.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + /// Copy the schedule's instructions into `@State` once, at appear. + /// + /// Deliberately a snapshot, not a computed lookup: the cached + /// `LocalScheduledInspection` is deleted by `resolveAndFulfillSchedule()` + /// the instant Submit is tapped, and this view stays on screen for another + /// 2.5 s afterwards. A computed property would re-read a deleted + /// `PersistentModel` during that window and trap. + private func loadScheduleInstructions() { + guard let schedId = inspection.scheduledInspectionServerId else { return } + // Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3). + let all = (try? context.fetch(FetchDescriptor())) ?? [] + scheduleInstructions = all.first(where: { $0.serverId == schedId })?.instructions + } + // ── Header Card ─────────────────────────────────────────────────────── private var headerCard: some View { @@ -576,6 +648,15 @@ struct ExecuteInspectionView: View { // regardless of connectivity or sync timing. clearParentFollowUpFlag() + // ── Fulfil the originating scheduled inspection ─────────────────── + // Two jobs, both mirroring the web app's execute route: + // 1. Make sure the submission carries scheduled_inspection_id, even + // when the inspector reached this form via "+" instead of the + // Scheduled row — the server cannot fulfil an unlinked inspection. + // 2. Drop the cached schedule row so the SCHEDULED card clears the + // moment Submit is tapped, online or offline. + resolveAndFulfillSchedule() + try? context.save() isSubmitting = false @@ -638,6 +719,84 @@ struct ExecuteInspectionView: View { } } + // ── Scheduled inspection fulfilment ─────────────────────────────────── + + /// Link this submission to the schedule it satisfies, then drop the cached + /// schedule row. + /// + /// **Why the fallback link exists.** On the web, `scheduled_inspections.start` + /// is the *only* way to open a scheduled inspection, so the link is always + /// present. On the iPad the Scheduled row merely pre-selects facility + + /// template — the inspector can reach the identical form through the "+" + /// button, and that path leaves `scheduledInspectionServerId` nil. The + /// server then stores `scheduled_inspection_id = NULL`, never calls + /// `_fulfill_schedule()`, and the schedule stays **Active** on the web. + /// Matching facility + template here restores parity of outcome between the + /// two entry points. + /// + /// **Why the match is narrow.** Only schedules already due (due date on or + /// before today) are eligible, so an ad-hoc inspection today cannot silently + /// close out an occurrence planned for next month. Assignment must also fit: + /// unassigned schedules, or ones assigned to this inspector. When several + /// qualify, the earliest due date wins — that is the occurrence being worked. + /// + /// **Why the row is deleted rather than rolled forward.** `LocalScheduledInspection` + /// is a read-only cache and does not carry the phase43 recurrence detail + /// (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the + /// next due date cannot be computed correctly on device. Deleting invalidates + /// the cache instead: `pullScheduledInspections()` re-inserts recurring + /// schedules with the server-authoritative `next_due_date` on the next pull, + /// and a one-time schedule stays gone because the server has deactivated it. + /// The same pull also restores the row if the submission never lands, so a + /// failed sync self-heals. + private func resolveAndFulfillSchedule() { + // Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3). + let all = (try? context.fetch(FetchDescriptor())) ?? [] + guard !all.isEmpty else { return } + + // Fallback link for inspections not started from a Scheduled row. + // Re-inspections are excluded: a follow-up shares its parent's facility + // and template, so it would otherwise close out an unrelated planned + // occurrence. The web app keeps the two workflows separate the same way. + let isReInspection = inspection.parentServerId != nil || inspection.parentLocalId != nil + if inspection.scheduledInspectionServerId == nil && !isReInspection { + let tid = inspection.templateServerId + let fid = inspection.facilityServerId + let uid = inspection.inspectorUserId + let today = Self.dueDateFormatter.string(from: Date()) + + let candidate = all + .filter { + $0.templateServerId == tid && + $0.facilityServerId == fid && + ($0.inspectorId == nil || $0.inspectorId == uid) && + !$0.dueDateString.isEmpty && + $0.dueDateString <= today // ISO strings sort chronologically + } + .sorted { $0.dueDateString < $1.dueDateString } + .first + + if let candidate { + inspection.scheduledInspectionServerId = candidate.serverId + } + } + + // Clear the cached row for whichever schedule this submission fulfils. + guard let schedId = inspection.scheduledInspectionServerId, + let sched = all.first(where: { $0.serverId == schedId }) + else { return } + + context.delete(sched) + } + + /// "yyyy-MM-dd", matching `LocalScheduledInspection.dueDateString`. + private static let dueDateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + // ── Photo Handling ──────────────────────────────────────────────────── private func handlePhotoSelected(localPath: String, field: [String: Any]) { diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 394ab82..5be07ba 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -22,7 +22,7 @@ struct MyInspectionsView: View { @Environment(\.modelContext) private var context @State private var showNewInspection = false - @State private var scheduledStartTarget: LocalScheduledInspection? + @State private var scheduledStartTarget: ScheduledStartTarget? // Deletion confirmation state @State private var pendingDelete: LocalInspection? @@ -42,7 +42,7 @@ struct MyInspectionsView: View { if !scheduledAll.isEmpty { Section("Scheduled") { ForEach(scheduledAll) { s in - Button { scheduledStartTarget = s } label: { + Button { scheduledStartTarget = ScheduledStartTarget(s) } label: { ScheduledRow(schedule: s) } .buttonStyle(.plain) @@ -71,16 +71,21 @@ struct MyInspectionsView: View { } } } - // Cover attached to the stable List, not a Section. - .fullScreenCover(item: $scheduledStartTarget) { s in - StartInspectionView( - preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId, - preFillScheduleId: s.serverId - ) - } } } + // Cover attached to the enclosing Group, not the List and never a + // Section (rule 64). The List itself is conditional: submitting the last + // scheduled inspection can flip this view to ContentUnavailableView while + // the cover is still presented, which would tear the form down mid-submit. + // The Group is always present. + .fullScreenCover(item: $scheduledStartTarget) { t in + StartInspectionView( + preFillTemplateId: t.templateServerId, + preFillFacilityId: t.facilityServerId, + preFillScheduleId: t.id, + preFillScheduleInstructions: t.instructions + ) + } .navigationTitle("My Inspections") // Confirmation before deletion — destructive action cannot be undone .alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in diff --git a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift index df684fa..3090e20 100644 --- a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift @@ -5,17 +5,54 @@ // SyncManager.pullScheduledInspections(). // // Two consumers share one ScheduledRow: -// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView +// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView. +// Presentational only; it reports taps via `onStart` and DashboardStatsView +// owns the .fullScreenCover on its always-present ScrollView. // • MyInspectionsView renders its own "Scheduled" List section inline, -// reusing ScheduledRow, with the start cover attached to the List. +// reusing ScheduledRow, with the start cover on the enclosing Group. // Both self-hide when there are no scheduled inspections and present // StartInspectionView (facility + template preselected) when a row is tapped. -// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping -// "Start" simply seeds the normal new-inspection flow. +// Both cover owners are views that outlive the schedule rows themselves — +// submitting the last scheduled inspection empties the @Query while the cover is +// still up, so a cover owned by the self-hiding card would be torn down with it. +// +// The schedule lifecycle stays server-driven: the submission carries +// `scheduled_inspection_id` and the server deactivates (one-time) or rolls +// forward (recurring). ExecuteInspectionView only invalidates the local cache +// row; pullScheduledInspections() re-reads the authoritative state. import SwiftUI import SwiftData +// MARK: - Start target snapshot + +/// Plain-value snapshot of the tapped schedule, used as the `.fullScreenCover` +/// item instead of the `LocalScheduledInspection` itself. +/// +/// The model object is unsafe to hold across the presentation: the cached row is +/// deleted while the cover is still on screen — by `resolveAndFulfillSchedule()` +/// the instant Submit is tapped, and by `pullScheduledInspections()` once the +/// server stops returning it. Reading a deleted `PersistentModel` traps, and a +/// `@Query` that empties out would also tear the cover down mid-submit. Copying +/// the values at tap time removes both hazards. +struct ScheduledStartTarget: Identifiable { + /// Schedule `serverId` — also the identity for `.fullScreenCover(item:)`. + let id: Int + let templateServerId: Int + let facilityServerId: Int + /// Manager-authored instructions for this occurrence. Server field is still + /// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the + /// user-facing wording is "Instructions". + let instructions: String? + + init(_ schedule: LocalScheduledInspection) { + self.id = schedule.serverId + self.templateServerId = schedule.templateServerId + self.facilityServerId = schedule.facilityServerId + self.instructions = schedule.instructions + } +} + // MARK: - Shared row struct ScheduledRow: View { @@ -60,6 +97,24 @@ struct ScheduledRow: View { .foregroundStyle(.tertiary) } } + + // Instructions preview — so the inspector can see there is + // something to read before committing to the tap. Truncated to + // one line; the full text is shown on the start screen and + // again above the form itself. + if let instructions = schedule.instructions { + HStack(alignment: .top, spacing: 4) { + Image(systemName: "info.circle.fill") + .font(.caption2) + .foregroundStyle(.blue) + Text(instructions) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.top, 1) + } } Spacer(minLength: 8) @@ -81,7 +136,14 @@ struct ScheduledInspectionsCard: View { @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) private var scheduled: [LocalScheduledInspection] - @State private var startTarget: LocalScheduledInspection? = nil + /// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT + /// (`DashboardStatsView`, on its always-present ScrollView) rather than here: + /// this card self-hides, and submitting the last scheduled inspection removes + /// the final row while the cover is still on screen. A cover owned by a view + /// that disappears is torn down with it, yanking the form away from the + /// inspector mid-submit. Keeping the card purely presentational also keeps + /// the empty case a true `EmptyView`, so the dashboard stack adds no spacing. + let onStart: (ScheduledStartTarget) -> Void var body: some View { if !scheduled.isEmpty { @@ -92,7 +154,7 @@ struct ScheduledInspectionsCard: View { .tracking(1) ForEach(scheduled) { s in - Button { startTarget = s } label: { + Button { onStart(ScheduledStartTarget(s)) } label: { ScheduledRow(schedule: s) .padding(12) .background(Color(.secondarySystemBackground)) @@ -101,14 +163,6 @@ struct ScheduledInspectionsCard: View { .buttonStyle(.plain) } } - // Cover attached to the stable VStack root (mirrors DraftResumeBanner). - .fullScreenCover(item: $startTarget) { s in - StartInspectionView( - preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId, - preFillScheduleId: s.serverId - ) - } } } } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index 10fd835..d356377 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -44,6 +44,13 @@ struct StartInspectionView: View { /// the schedule and the "Scheduled" banner never clears. var preFillScheduleId: Int? = nil + /// Instructions the manager attached to this schedule ("Instructions" in the + /// UI; still `notes` on the wire and in the DB). Passed as a plain String + /// rather than read from SwiftData here, for the same reason + /// ScheduledStartTarget exists — the cached schedule row is deleted at + /// submit while this flow is still on screen. + var preFillScheduleInstructions: String? = nil + // ── Derived lists ───────────────────────────────────────────────────── /// Unique contracts (projectId, projectName) sorted by name. @@ -85,6 +92,25 @@ struct StartInspectionView: View { var body: some View { NavigationStack { Form { + // ── Instructions from the schedule ───────────────────────── + // Shown first: this is the reason the inspector was sent here, + // and it may change what they carry in with them. Repeated + // above the form itself in ExecuteInspectionView. + if let instructions = preFillScheduleInstructions, + !instructions.isEmpty { + Section { + VStack(alignment: .leading, spacing: 6) { + Label("Instructions", systemImage: "info.circle.fill") + .font(.callout.bold()) + .foregroundStyle(.blue) + Text(instructions) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + } + // ── Re-inspection notice ─────────────────────────────────── if parentServerId != nil { Section {