From 5999871f77c711824f122134ed1da0ceb7568f2e Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sun, 26 Jul 2026 14:56:39 -0400 Subject: [PATCH 1/8] Jul 26 - Update scheduled inspection settings --- JanitorialQC/API/APIClient.swift | 5 +++++ JanitorialQC/CLAUDE.md | 10 ++++++++-- JanitorialQC/Models/LocalInspection.swift | 11 +++++++++++ JanitorialQC/Views/Dashboard/MyInspectionsView.swift | 3 ++- .../Views/Dashboard/ScheduledInspectionsView.swift | 3 ++- .../Views/Dashboard/StartInspectionView.swift | 9 +++++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 5cf9f37..da1ebbe 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -238,6 +238,11 @@ actor APIClient { if let score = inspection.overallScore { body["overall_score"] = score } if let areaId = inspection.areaServerId { body["area_id"] = areaId } if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId } + // Links the submission back to the schedule it was started from so the + // server fulfils it (clears the banner) and badges it as "Scheduled". + if let schedId = inspection.scheduledInspectionServerId { + body["scheduled_inspection_id"] = schedId + } if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } if let lat = inspection.submitLatitude { body["submit_latitude"] = lat } if let lng = inspection.submitLongitude { body["submit_longitude"] = lng } diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index cabdf36..c74b09c 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -193,7 +193,7 @@ PendingPhoto.self, SyncQueueEntry.self | `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) | | `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` | | `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) | -| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `submitLatitude` (Double?), `submitLongitude` (Double?) | +| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) | | `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) | | `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` | | `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` | @@ -381,7 +381,13 @@ Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange ### Scheduled inspections (phase36, July 2026) -`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it). +`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:preFillScheduleId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it). + +**Fulfilling the schedule (July 2026 fix).** `preFillScheduleId` is the schedule's `serverId`; `startInspection()` copies it onto `LocalInspection.scheduledInspectionServerId`, and `submitInspection()` sends it as **`scheduled_inspection_id`**. The server then fulfils the schedule (one-time → deactivated, recurring → rolled forward) in the same commit as the inspection. + +Without it — the original bug — the schedule was never fulfilled: the banner stayed on the inspector's Dashboard and My Inspections, it stayed on the web dashboard for admin/director, and the web inspection list showed no "Scheduled" badge. **Both** Start call sites must pass `preFillScheduleId` (`ScheduledInspectionsCard` and the `MyInspectionsView` inline section); re-inspection launches correctly leave it nil. + +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. ### Inspection-start presentation (July 2026) diff --git a/JanitorialQC/Models/LocalInspection.swift b/JanitorialQC/Models/LocalInspection.swift index 0e06fb3..c27f0cd 100644 --- a/JanitorialQC/Models/LocalInspection.swift +++ b/JanitorialQC/Models/LocalInspection.swift @@ -51,6 +51,16 @@ final class LocalInspection { /// followUpRequired badge without relying on parentServerId being non-nil. var parentLocalId: String? + // ── Scheduled inspection link ────────────────────────────────────────── + /// Server ID of the ScheduledInspection this inspection was started from, + /// set when the inspector taps Start on a scheduled row. Sent as + /// `scheduled_inspection_id` on submit so the server can fulfil the + /// schedule (deactivate a one-time / roll a recurring one forward) and + /// flag the inspection as "Scheduled" in the web list. Nil for ad-hoc work. + /// + /// Declared with an inline default so existing stores migrate lightweight. + var scheduledInspectionServerId: Int? = nil + // ── GPS (captured at submit time via CoreLocation) ───────────────────── /// Device latitude at the moment the inspector tapped Submit. Nil if /// location permission was denied or a fix could not be obtained in time. @@ -89,6 +99,7 @@ final class LocalInspection { self.followUpNote = nil self.parentServerId = nil self.parentLocalId = nil + self.scheduledInspectionServerId = nil self.submitLatitude = nil self.submitLongitude = nil self.pendingPhotos = [] diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 0431ee0..394ab82 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -75,7 +75,8 @@ struct MyInspectionsView: View { .fullScreenCover(item: $scheduledStartTarget) { s in StartInspectionView( preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId + preFillFacilityId: s.facilityServerId, + preFillScheduleId: s.serverId ) } } diff --git a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift index 7cd6f71..df684fa 100644 --- a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift @@ -105,7 +105,8 @@ struct ScheduledInspectionsCard: View { .fullScreenCover(item: $startTarget) { s in StartInspectionView( preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId + preFillFacilityId: s.facilityServerId, + preFillScheduleId: s.serverId ) } } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index 3e4c904..10fd835 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -37,6 +37,13 @@ struct StartInspectionView: View { var parentServerId: Int? = nil var parentLocalId: String? = nil + // ── Scheduled inspection launch ─────────────────────────────────────── + /// Server ID of the ScheduledInspection this run fulfils, passed when the + /// inspector taps Start on a scheduled row. Carried onto the LocalInspection + /// so submitInspection() can send it; without it the server cannot fulfil + /// the schedule and the "Scheduled" banner never clears. + var preFillScheduleId: Int? = nil + // ── Derived lists ───────────────────────────────────────────────────── /// Unique contracts (projectId, projectName) sorted by name. @@ -266,6 +273,8 @@ struct StartInspectionView: View { // Link to parent if this is a re-inspection inspection.parentServerId = parentServerId inspection.parentLocalId = parentLocalId + // Link to the schedule if launched from a scheduled row + inspection.scheduledInspectionServerId = preFillScheduleId // ── Pre-fill from parent (mirrors web app behaviour) ─────────────── // Copy non-scoring field values from the parent inspection so the From ad91a92ff175cede23af6e73d97dcf1b765e55bf Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 27 Jul 2026 12:57:13 -0400 Subject: [PATCH 2/8] Jul 27 - Update for scheduled task instruction --- JanitorialQC.xcodeproj/project.pbxproj | 4 +- JanitorialQC/CLAUDE.md | 11 +- JanitorialQC/DEPLOYMENT_MAP.txt | 61 +++++++ .../Models/LocalScheduledInspection.swift | 18 ++ JanitorialQC/PLACEMENT_MAP.txt | 44 +++++ .../Views/Dashboard/DashboardView.swift | 22 ++- .../Dashboard/ExecuteInspectionView.swift | 159 ++++++++++++++++++ .../Views/Dashboard/MyInspectionsView.swift | 25 +-- .../Dashboard/ScheduledInspectionsView.swift | 82 +++++++-- .../Views/Dashboard/StartInspectionView.swift | 26 +++ 10 files changed, 424 insertions(+), 28 deletions(-) create mode 100644 JanitorialQC/DEPLOYMENT_MAP.txt create mode 100644 JanitorialQC/PLACEMENT_MAP.txt 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 { From 4132373fee3012cd5379ce8db9fd77886dcfc612 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 27 Jul 2026 15:56:19 -0400 Subject: [PATCH 3/8] Jul 27 - Update code for scheduled tasks 3 --- JanitorialQC/CLAUDE.md | 7 ++- .../Models/LocalScheduledInspection.swift | 26 ++++++++ JanitorialQC/PLACEMENT_MAP.txt | 62 +++++++++---------- .../Dashboard/ExecuteInspectionView.swift | 14 ++++- .../Views/Dashboard/MyInspectionsView.swift | 11 +++- .../Dashboard/ScheduledInspectionsView.swift | 12 +++- 6 files changed, 91 insertions(+), 41 deletions(-) diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index 937faec..e8b0f37 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -391,7 +391,9 @@ No SyncManager change was needed: `pullScheduledInspections()` already runs afte **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. +**Correction (July 2026).** That first cut *deleted* the cached row at submit. It shipped, and recurring schedules then stopped picking up their new due date — see rule 71. `resolveAndFulfillSchedule()` now sets `fulfilledLocally` instead, and both scheduled lists filter on it. The cover-ownership work below still stands: `pullScheduledInspections()` continues to delete rows for one-time schedules, so consumers must still hold value snapshots rather than the model. + +Flagging 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. @@ -693,8 +695,9 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 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. | +| 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()`. | --- diff --git a/JanitorialQC/Models/LocalScheduledInspection.swift b/JanitorialQC/Models/LocalScheduledInspection.swift index b6b5b50..38f6d39 100644 --- a/JanitorialQC/Models/LocalScheduledInspection.swift +++ b/JanitorialQC/Models/LocalScheduledInspection.swift @@ -38,6 +38,27 @@ final class LocalScheduledInspection { var isOverdue: Bool var notes: String? + /// Set on device the moment an inspection fulfilling this schedule is + /// submitted, so the SCHEDULED lists hide the row immediately — online or + /// offline — without waiting for the round trip. + /// + /// Purely a display flag. The schedule lifecycle stays server-driven: the + /// next successful pull calls `update(from:)`, which clears this and writes + /// the authoritative `next_due_date`. If the submission never lands, the + /// server still reports the schedule as due and the row simply comes back — + /// self-healing. + /// + /// This exists instead of deleting the row. A recurring schedule is + /// returned by the server *again* after it rolls forward, so deleting meant + /// delete-then-reinsert against an `@Attribute(.unique)` key on every + /// completion, and the reinserted row did not reliably carry the new due + /// date. Flagging keeps `update(from:)` — the path that has always worked — + /// as the only way a cached row's dates ever change. + /// + /// Non-optional with an inline default, so SwiftData migrates lightweight + /// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall. + var fulfilledLocally: Bool = false + /// Manager-authored instructions for this occurrence, normalised. /// /// The wire field, the server model attribute and the DB column are all @@ -84,6 +105,7 @@ final class LocalScheduledInspection { self.dueDateString = api.nextDueDate ?? "" self.isOverdue = api.isOverdue self.notes = api.notes + self.fulfilledLocally = false self.updatedAt = Date() } @@ -98,6 +120,10 @@ final class LocalScheduledInspection { self.dueDateString = api.nextDueDate ?? "" self.isOverdue = api.isOverdue self.notes = api.notes + // The server is authoritative. Being returned by the pull at all means + // this schedule is live again — for a recurring one, on its NEXT + // occurrence — so any local "just did it" flag is stale by definition. + self.fulfilledLocally = false self.updatedAt = Date() } } diff --git a/JanitorialQC/PLACEMENT_MAP.txt b/JanitorialQC/PLACEMENT_MAP.txt index c4c6b3b..a0188de 100644 --- a/JanitorialQC/PLACEMENT_MAP.txt +++ b/JanitorialQC/PLACEMENT_MAP.txt @@ -1,44 +1,42 @@ -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). +JQC iOS — recurring schedules keep showing the old due date +=========================================================== +Repo: jqc_ios_app iOS ONLY — no server change, no migration. -DROP-IN (overwrite): +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/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 new files, none removed, no .xcodeproj edit. -NO server changes. NO SwiftData schema change, so no migration -and no need to delete the app from the iPad. +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 - 1. git pull / copy files in - 2. Xcode → Product → Clean Build Folder (Shift-Cmd-K) - 3. Build & run on the iPad + Product > Clean Build Folder (Shift-Cmd-K), then run. 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 + 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 = ; + 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. -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 +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" diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 79de01a..2d2dd7a 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -770,6 +770,7 @@ struct ExecuteInspectionView: View { $0.templateServerId == tid && $0.facilityServerId == fid && ($0.inspectorId == nil || $0.inspectorId == uid) && + !$0.fulfilledLocally && // already satisfied, awaiting sync !$0.dueDateString.isEmpty && $0.dueDateString <= today // ISO strings sort chronologically } @@ -781,12 +782,21 @@ struct ExecuteInspectionView: View { } } - // Clear the cached row for whichever schedule this submission fulfils. + // Hide the row until the server confirms what happened to it. + // + // NOT a delete. A recurring schedule comes back from the server on its + // next occurrence, so deleting turned every completion into a + // delete-then-reinsert against the `@Attribute(.unique)` serverId, and + // the reinserted row did not reliably pick up the new due date — a + // daily schedule kept showing today's date after being completed. + // One-time schedules masked it, because the server stops returning them + // and they are never reinserted. Flagging leaves `update(from:)` as the + // single path that ever writes a cached schedule's dates. guard let schedId = inspection.scheduledInspectionServerId, let sched = all.first(where: { $0.serverId == schedId }) else { return } - context.delete(sched) + sched.fulfilledLocally = true } /// "yyyy-MM-dd", matching `LocalScheduledInspection.dueDateString`. diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 5be07ba..548dde4 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -19,6 +19,11 @@ struct MyInspectionsView: View { @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) private var scheduledAll: [LocalScheduledInspection] + /// Rows still awaiting action — see ScheduledInspectionsCard.visible. + private var scheduledVisible: [LocalScheduledInspection] { + scheduledAll.filter { !$0.fulfilledLocally } + } + @Environment(\.modelContext) private var context @State private var showNewInspection = false @@ -30,7 +35,7 @@ struct MyInspectionsView: View { var body: some View { Group { - if inspections.isEmpty && scheduledAll.isEmpty { + if inspections.isEmpty && scheduledVisible.isEmpty { ContentUnavailableView( "No Inspections", systemImage: "checklist", @@ -39,9 +44,9 @@ struct MyInspectionsView: View { } else { List { // Scheduled assignments (phase36) — self-hides when empty. - if !scheduledAll.isEmpty { + if !scheduledVisible.isEmpty { Section("Scheduled") { - ForEach(scheduledAll) { s in + ForEach(scheduledVisible) { s in Button { scheduledStartTarget = ScheduledStartTarget(s) } label: { ScheduledRow(schedule: s) } diff --git a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift index 3090e20..e7c38de 100644 --- a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift @@ -136,6 +136,14 @@ struct ScheduledInspectionsCard: View { @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) private var scheduled: [LocalScheduledInspection] + /// Rows still awaiting action. Filtered in Swift rather than in the @Query + /// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and + /// cleared by the next pull, so a completed schedule leaves the card at + /// once and reappears only when the server says it is due again. + private var visible: [LocalScheduledInspection] { + scheduled.filter { !$0.fulfilledLocally } + } + /// 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 @@ -146,14 +154,14 @@ struct ScheduledInspectionsCard: View { let onStart: (ScheduledStartTarget) -> Void var body: some View { - if !scheduled.isEmpty { + if !visible.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("SCHEDULED") .font(.caption.bold()) .foregroundStyle(.secondary) .tracking(1) - ForEach(scheduled) { s in + ForEach(visible) { s in Button { onStart(ScheduledStartTarget(s)) } label: { ScheduledRow(schedule: s) .padding(12) From 9926739cb673c69b0bbef5eeaac7fb3eab8ec40a Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 27 Jul 2026 16:43:42 -0400 Subject: [PATCH 4/8] Jul 27 - Update code for scheduled tasks 4 --- JanitorialQC.xcodeproj/project.pbxproj | 2 - JanitorialQC/CLAUDE.md | 4 ++ JanitorialQC/Info.plist | 22 ++++++++- JanitorialQC/JanitorialQCApp.swift | 45 +++++++++++++++++-- JanitorialQC/PLACEMENT_MAP.txt | 42 ----------------- JanitorialQC/Sync/SyncManager.swift | 40 +++++++++++++++++ JanitorialQC/Views/Dashboard/IssuesView.swift | 4 ++ .../Views/Dashboard/MyInspectionsView.swift | 7 ++- PLACEMENT_MAP.txt | 45 +++++++++++++++++++ 9 files changed, 162 insertions(+), 49 deletions(-) delete mode 100644 JanitorialQC/PLACEMENT_MAP.txt create mode 100644 PLACEMENT_MAP.txt diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 7b1970f..e51bab4 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -416,7 +416,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = JanitorialQC/Info.plist; - INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync; INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues."; @@ -460,7 +459,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = JanitorialQC/Info.plist; - INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync; INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues."; diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index e8b0f37..fd2b239 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -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 /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. | --- diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist index 0c67376..b9ecf73 100644 --- a/JanitorialQC/Info.plist +++ b/JanitorialQC/Info.plist @@ -1,5 +1,25 @@ - + + + BGTaskSchedulerPermittedIdentifiers + + com.jqc.sync + + UIBackgroundModes + + processing + + diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index a0effae..ebd886b 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -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)") + } } diff --git a/JanitorialQC/PLACEMENT_MAP.txt b/JanitorialQC/PLACEMENT_MAP.txt deleted file mode 100644 index a0188de..0000000 --- a/JanitorialQC/PLACEMENT_MAP.txt +++ /dev/null @@ -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 = ; - 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" diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 07d91f5..f959269 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -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) diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index 2a7085a..f4c3ae7 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -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) } } diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 548dde4..14079fc 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -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) { diff --git a/PLACEMENT_MAP.txt b/PLACEMENT_MAP.txt new file mode 100644 index 0000000..7a8759e --- /dev/null +++ b/PLACEMENT_MAP.txt @@ -0,0 +1,45 @@ +JQC iOS — FIX for App Store Connect error 90771 +=============================================== +Missing Info.plist value: BGTaskSchedulerPermittedIdentifiers + +This supersedes the Info.plist from the previous background-sync drop. +The Swift files from that drop are unchanged and still correct. + +OVERWRITE: + JanitorialQC/Info.plist <- now has BOTH keys, as arrays + JanitorialQC.xcodeproj/project.pbxproj <- removes 2 inert lines + JanitorialQC/CLAUDE.md + +WHAT WAS WRONG + INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync + was set in build settings (Debug + Release). INFOPLIST_KEY_* only merges + Xcode's recognised keys, and only as STRINGS. + BGTaskSchedulerPermittedIdentifiers must be an ARRAY, so nothing valid ever + reached the built Info.plist. It went unnoticed until UIBackgroundModes was + added, which is what makes the App Store validator check for it. + +pbxproj CHANGE IS EXACTLY 2 LINE DELETIONS (verified by diff): + line 419 (Debug) INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync; + line 463 (Release) INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync; +Nothing else touched. If you prefer not to take a pbxproj file, delete those +two lines by hand, or in Xcode: target > Build Settings > search "BGTask" > +select the row > Delete. + +DO NOT put that build setting back. A build setting overwrites the plist +file's value at merge time and the array becomes a string again. + +BUILD + VERIFY BEFORE UPLOADING + 1. Product > Clean Build Folder (Shift-Cmd-K) + 2. Product > Archive + 3. In Organizer: right-click the archive > Show in Finder > + Show Package Contents > Products/Applications/JanitorialQC.app + plutil -p JanitorialQC.app/Info.plist | grep -A3 -E "BGTask|UIBackground" + EXPECT: + "BGTaskSchedulerPermittedIdentifiers" => [ 0 => "com.jqc.sync" ] + "UIBackgroundModes" => [ 0 => "processing" ] + Both must be arrays. If either shows a bare string, stop - the build + setting is back. + 4. Then upload. Error 90771 should be gone. + + 5. On device, confirm the console shows NO + "[JQC] BGTaskScheduler.register FAILED" line at launch. From b7990cc9ba5e1566585a2ce5671d90eb950a9f77 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Wed, 29 Jul 2026 15:51:08 -0400 Subject: [PATCH 5/8] Jul 29 - Update codes to support iPhone --- JanitorialQC.xcodeproj/project.pbxproj | 4 +- JanitorialQC/CLAUDE.md | 6 +- .../Views/Dashboard/DashboardView.swift | 332 +++++++++++------- .../Dashboard/ExecuteInspectionView.swift | 158 +++++++-- .../Inspection/InspectionHistoryView.swift | 33 +- 5 files changed, 355 insertions(+), 178 deletions(-) diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index e51bab4..3030da1 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -431,7 +431,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.8; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -474,7 +474,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.8; 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 fd2b239..41491b7 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -62,7 +62,7 @@ Core capabilities: | Layer | Technology | |---|---| | Language | Swift 5.10+ | -| UI | SwiftUI (iPad-only, all four orientations) | +| UI | SwiftUI. iPad is the primary target; iPhone (compact width) is supported — see rules 76–77. All four orientations. `TARGETED_DEVICE_FAMILY = "1,2"`, so the app installs on iPhone and must stay usable there | | Local storage | SwiftData (iOS 17+ required) | | Networking | URLSession async/await | | Connectivity detection | NWPathMonitor (Network.framework) | @@ -359,6 +359,8 @@ JanitorialQCApp **`NavigationSplitView` constraint:** `init(selection:content:)` unavailable on iPadOS 17. Use `@State var selectedTab: SidebarTab` with `Button` handlers. **Never add a `selection:` binding.** +**Compact width takes a different tree entirely.** That Button-driven sidebar cannot navigate once the split view collapses (rule 76), so `DashboardView.body` branches on `horizontalSizeClass`: `regularBody` is the `NavigationSplitView` above, `compactBody` is a `NavigationStack` whose rows are `NavigationLink`s. Both share `sidebarRowLabel(_:tinted:)` and `detailRoot(for:)` — the latter returns destination content *without* a `NavigationStack` wrapper so the call site can supply one (iPad) or push it (iPhone). Add new destinations to `sidebarTabs` + both helpers, never to one body only. + **`+` button placement:** The new inspection `+` button lives on `MyInspectionsView` (not the sidebar) so it remains accessible when the sidebar is collapsed. The new issue `+` button lives on `IssuesListView`. --- @@ -702,6 +704,8 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 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 /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. | +| 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). | +| 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. | --- diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 9eb8d04..ef16b1d 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -34,6 +34,7 @@ struct DashboardView: View { @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var hSizeClass @Query( filter: #Predicate { $0.status != "synced" }, @@ -71,141 +72,26 @@ struct DashboardView: View { selectedTab = tab } + /// Sidebar order — single source of truth for both the regular-width + /// sidebar and the compact-width root list. + private let sidebarTabs: [SidebarTab] = [ + .dashboard, .myInspections, .issues, .facilities, + .pendingSync, .history, .notifications, .settings, + ] + var body: some View { - NavigationSplitView { - List { - // ── Dashboard ────────────────────────────────────────────── - Button { selectTab(.dashboard) } label: { - Label("Dashboard", systemImage: "chart.bar.xaxis") - .foregroundStyle(selectedTab == .dashboard ? .blue : .primary) - } - .listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear) - - // ── My Inspections ───────────────────────────────────────── - Button { selectTab(.myInspections) } label: { - HStack { - Label("My Inspections", systemImage: "checklist") - .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) - Spacer() - if !myInspections.isEmpty { - Text("\(myInspections.count)") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.blue.opacity(0.15)) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) - - // ── Issues (all roles) ───────────────────────────────────── - Button { selectTab(.issues) } label: { - Label("Issues", systemImage: "exclamationmark.triangle") - .foregroundStyle(selectedTab == .issues ? .blue : .primary) - } - .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) - - // ── Facilities ───────────────────────────────────────────── - Button { selectTab(.facilities) } label: { - Label("Facilities", systemImage: "building.2") - .foregroundStyle(selectedTab == .facilities ? .blue : .primary) - } - .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) - - // ── Pending Sync ─────────────────────────────────────────── - Button { selectTab(.pendingSync) } label: { - HStack { - Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") - .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) - Spacer() - if sync.pendingCount > 0 { - Text("\(sync.pendingCount)") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.orange.opacity(0.2)) - .foregroundStyle(.orange) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) - - // ── History (moved — sits between Pending Sync and Settings) - Button { selectTab(.history) } label: { - Label("History", systemImage: "clock.arrow.circlepath") - .foregroundStyle(selectedTab == .history ? .blue : .primary) - } - .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) - - // ── Notifications ───────────────────────────────────────── - Button { - selectTab(.notifications) - sync.markNotificationsViewed() - } label: { - HStack { - Label("Notifications", systemImage: "bell") - .foregroundStyle(selectedTab == .notifications ? .blue : .primary) - Spacer() - if sync.unreadNotificationCount > 0 { - Text("\(min(sync.unreadNotificationCount, 99))") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.red.opacity(0.85)) - .foregroundStyle(.white) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear) - - // ── Settings ─────────────────────────────────────────────── - Button { selectTab(.settings) } label: { - Label("Settings", systemImage: "gear") - .foregroundStyle(selectedTab == .settings ? .blue : .primary) - } - .listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear) - } - .navigationTitle("JQC Inspector") - .listStyle(.sidebar) - .safeAreaInset(edge: .bottom) { syncStatusFooter } - - } detail: { - switch selectedTab { - case .dashboard: - NavigationStack { DashboardStatsView() } - case .myInspections: - NavigationStack(path: $inspectionsPath) { - MyInspectionsView() - .navigationDestination(for: LocalInspection.self) { inspection in - if inspection.status == "draft" { - ExecuteInspectionView(inspection: inspection) - } else { - CompletedInspectionView(inspection: inspection) - } - } - } - case .issues: - NavigationStack(path: $issuesPath) { - IssuesListView() - .navigationDestination(for: LocalIssue.self) { issue in - IssueDetailView(issue: issue) - } - } - case .facilities: - NavigationStack { FacilitiesListView() } - case .pendingSync: - NavigationStack { SyncStatusView() } - case .history: - NavigationStack(path: $historyPath) { - InspectionHistoryView() - .navigationDestination(for: APIInspectionSummary.self) { inspection in - HistoryDetailView(inspection: inspection) - } - } - case .notifications: - NavigationStack { NotificationsView() } - case .settings: - NavigationStack { SettingsView() } + Group { + // On compact width (iPhone) a NavigationSplitView collapses to show + // ONLY the sidebar: its `detail:` column is never presented, because + // nothing pushes it. The rows here are plain Buttons driving @State + // (rule 2 forbids a `selection:` binding), and a state change alone + // cannot push the detail column — so every destination was + // unreachable on iPhone. Compact width therefore gets a real + // NavigationStack whose rows are NavigationLinks. + if hSizeClass == .compact { + compactBody + } else { + regularBody } } .task { @@ -223,6 +109,181 @@ struct DashboardView: View { } } + // ── Regular width (iPad) — unchanged two-column split view ──────────── + + private var regularBody: some View { + NavigationSplitView { + List { + ForEach(sidebarTabs, id: \.self) { tab in + Button { + selectTab(tab) + if tab == .notifications { sync.markNotificationsViewed() } + } label: { + sidebarRowLabel(tab, tinted: selectedTab == tab) + } + .listRowBackground( + selectedTab == tab ? Color.blue.opacity(0.1) : Color.clear + ) + } + } + .navigationTitle("JQC Inspector") + .listStyle(.sidebar) + .safeAreaInset(edge: .bottom) { syncStatusFooter } + + } detail: { + switch selectedTab { + case .myInspections: + NavigationStack(path: $inspectionsPath) { detailRoot(for: .myInspections) } + case .issues: + NavigationStack(path: $issuesPath) { detailRoot(for: .issues) } + case .history: + NavigationStack(path: $historyPath) { detailRoot(for: .history) } + default: + NavigationStack { detailRoot(for: selectedTab) } + } + } + } + + // ── Compact width (iPhone) — push-based stack ───────────────────────── + // One NavigationStack whose root is the same destination list. Rows are + // NavigationLinks so tapping actually pushes. The per-tab paths used by + // the iPad split view are not needed here: this single stack owns the + // whole hierarchy, and the nested `.navigationDestination`s declared in + // detailRoot(for:) register against it. + + private var compactBody: some View { + NavigationStack { + List { + ForEach(sidebarTabs, id: \.self) { tab in + NavigationLink(value: tab) { + sidebarRowLabel(tab, tinted: false) + } + } + } + .navigationTitle("JQC Inspector") + .navigationDestination(for: SidebarTab.self) { detailRoot(for: $0) } + .safeAreaInset(edge: .bottom) { syncStatusFooter } + } + } + + // ── Shared row label ────────────────────────────────────────────────── + + @ViewBuilder + private func sidebarRowLabel(_ tab: SidebarTab, tinted: Bool) -> some View { + let tint: Color = tinted ? .blue : .primary + switch tab { + case .dashboard: + Label("Dashboard", systemImage: "chart.bar.xaxis") + .foregroundStyle(tint) + + case .myInspections: + HStack { + Label("My Inspections", systemImage: "checklist") + .foregroundStyle(tint) + Spacer() + if !myInspections.isEmpty { + Text("\(myInspections.count)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.blue.opacity(0.15)) + .clipShape(Capsule()) + } + } + + case .issues: + Label("Issues", systemImage: "exclamationmark.triangle") + .foregroundStyle(tint) + + case .facilities: + Label("Facilities", systemImage: "building.2") + .foregroundStyle(tint) + + case .pendingSync: + HStack { + Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") + .foregroundStyle(tint) + Spacer() + if sync.pendingCount > 0 { + Text("\(sync.pendingCount)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.orange.opacity(0.2)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } + } + + case .history: + Label("History", systemImage: "clock.arrow.circlepath") + .foregroundStyle(tint) + + case .notifications: + HStack { + Label("Notifications", systemImage: "bell") + .foregroundStyle(tint) + Spacer() + if sync.unreadNotificationCount > 0 { + Text("\(min(sync.unreadNotificationCount, 99))") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.red.opacity(0.85)) + .foregroundStyle(.white) + .clipShape(Capsule()) + } + } + + case .settings: + Label("Settings", systemImage: "gear") + .foregroundStyle(tint) + } + } + + // ── Shared destination root ─────────────────────────────────────────── + // The NavigationStack wrapper lives at the call site, so the same content + // serves as a split-view detail root (iPad) and a pushed view (iPhone). + + @ViewBuilder + private func detailRoot(for tab: SidebarTab) -> some View { + switch tab { + case .dashboard: + DashboardStatsView() + + case .myInspections: + MyInspectionsView() + .navigationDestination(for: LocalInspection.self) { inspection in + if inspection.status == "draft" { + ExecuteInspectionView(inspection: inspection) + } else { + CompletedInspectionView(inspection: inspection) + } + } + + case .issues: + IssuesListView() + .navigationDestination(for: LocalIssue.self) { issue in + IssueDetailView(issue: issue) + } + + case .facilities: + FacilitiesListView() + + case .pendingSync: + SyncStatusView() + + case .history: + InspectionHistoryView() + .navigationDestination(for: APIInspectionSummary.self) { inspection in + HistoryDetailView(inspection: inspection) + } + + case .notifications: + NotificationsView() + + case .settings: + SettingsView() + } + } + private var syncStatusFooter: some View { VStack(spacing: 0) { Divider() @@ -460,6 +521,9 @@ struct DashboardStatsView: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) + // Tiles are 2-up, so on a phone each is ~170 pt wide and + // longer labels ("Open / In Progress") would truncate. + .minimumScaleFactor(0.75) } Text(value) .font(.system(size: 32, weight: .bold, design: .rounded)) diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 2d2dd7a..be25f0e 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -870,6 +870,25 @@ struct GridFormView: View { static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W static let cardPadding: CGFloat = 16 // card inset on all sides + // Below this container width the 12-column grid stops being usable: at + // 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt + // tall, so a normal 6x2 field renders ~167x35 pt — the label alone eats + // most of it. Cells are absolutely positioned and deliberately unclipped + // (see body), so the overflow draws on top of the row beneath and the + // form becomes an unreadable pile. Under this width we reflow to one + // field per line instead. 600 pt keeps a column at >=40 pt. + static let minGridWidth: CGFloat = 600 + + // Heights for widgets that have no intrinsic size of their own. In the + // absolute grid these are driven by rowSpan; in the stacked layout there + // is no rowSpan to read, so they would otherwise collapse to nothing. + static let stackedMinH: [String: CGFloat] = [ + "textarea": 96, + "signature": 120, + "table": 120, + "image": 88, + ] + // Minimum cell height (points) per field type — ensures 44pt touch targets // on iPad even when the template author assigned a very short rowSpan. static let minCellH: [String: CGFloat] = [ @@ -894,54 +913,115 @@ struct GridFormView: View { @State private var containerWidth: CGFloat = 0 var body: some View { - ZStack(alignment: .topLeading) { - // ── Card background ──────────────────────────────────────────── - RoundedRectangle(cornerRadius: 12) - .fill(Color(.secondarySystemBackground)) + Group { + if containerWidth > 0 && isCompact { + // ── Compact: content drives the height ───────────────────── + // The card is a .background modifier rather than a ZStack + // sibling so it takes its size FROM the stack. As a ZStack + // sibling the flexible RoundedRectangle competes with the + // VStack for the container's size and the card ends up + // shorter than its own content, cutting off the last fields. + stackedLayout + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) + ) + } else { + // ── Regular: absolute 12-column canvas ───────────────────── + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) - // ── Width probe — zero-size overlay, reports container width ─── - // Using a background Color.clear with a GeometryReader that sends - // its width via PreferenceKey is the idiomatic SwiftUI pattern that - // works correctly inside ScrollView on all iOS versions. - Color.clear - .frame(maxWidth: .infinity) - .frame(height: 0) - .background( - GeometryReader { geo in - Color.clear.preference( - key: WidthPreferenceKey.self, - value: geo.size.width - ) - } - ) + // Field overlays — only rendered after width is measured. + // containerWidth == 0 means the PreferenceKey has not fired + // yet (first layout pass). Skipping the overlay pass on the + // zero frame prevents fields from being positioned using a + // stale width and overflowing the modal on narrow sheet + // presentations (iPad 10th gen). + if containerWidth > 0 { + let cellW = computedCellW + let cellH = cellW * Self.cellAspect - // ── Field overlays — only rendered after width is measured ───── - // containerWidth == 0 means the PreferenceKey has not fired yet - // (first layout pass). Skipping the overlay pass on the zero frame - // prevents fields from being positioned using a stale width and - // overflowing the modal on narrow sheet presentations (iPad 10th gen). - if containerWidth > 0 { - let cellW = computedCellW - let cellH = cellW * Self.cellAspect - - ForEach(schema.indices, id: \.self) { idx in - let field = schema[idx] - let ftype = field["type"] as? String ?? "text" - if !["button_submit", "button_print", "button_email"].contains(ftype) { - gridCell(field: field, cellW: cellW, cellH: cellH) + ForEach(schema.indices, id: \.self) { idx in + let field = schema[idx] + let ftype = field["type"] as? String ?? "text" + if !["button_submit", "button_print", "button_email"].contains(ftype) { + gridCell(field: field, cellW: cellW, cellH: cellH) + } + } } } + // Height is derived from the same arithmetic as the cell + // offsets — the ScrollView measures this frame and can never + // be wrong. + .frame(height: containerWidth > 0 + ? canvasHeight() + 2 * Self.cardPadding + : 0) } } + // ── Width probe ─────────────────────────────────────────────────── + // Attached as a background so it reports the resolved container width + // without taking part in sizing the content itself. + .background( + GeometryReader { geo in + Color.clear.preference( + key: WidthPreferenceKey.self, + value: geo.size.width + ) + } + ) .onPreferenceChange(WidthPreferenceKey.self) { width in if width > 0 { containerWidth = width } } - // Height is always derived from the same arithmetic as cell offsets — - // the ScrollView measures this frame and can never be wrong. - // When containerWidth is 0, canvasHeight() still returns the correct - // value (it uses computedCellW which returns 0 when containerWidth is 0), - // so the card reserves space and avoids a layout jump. - .frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0) + } + + // ── Compact (narrow) layout ─────────────────────────────────────────── + // One field per line, full width, natural height. Fields are ordered by + // (row, col) because the form editor stores them in drag/creation order, + // not visual order — the same sort the PDF and read-only renderers use + // (rule 62). Nothing is absolutely positioned here, so nothing can + // overlap regardless of how narrow the screen gets. + + private var isCompact: Bool { containerWidth < Self.minGridWidth } + + private var orderedFields: [[String: Any]] { + schema + .filter { f in + let t = f["type"] as? String ?? "text" + return !["button_submit", "button_print", "button_email"].contains(t) + } + .sorted { + let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0 + if r0 != r1 { return r0 < r1 } + return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0) + } + } + + private var stackedLayout: some View { + let fields = orderedFields + return VStack(alignment: .leading, spacing: 14) { + ForEach(fields.indices, id: \.self) { idx in + let field = fields[idx] + let ftype = field["type"] as? String ?? "text" + let fid = fieldId(field) + + GridCellContentView( + field: field, + value: Binding( + get: { formValues[fid] ?? "" }, + set: { formValues[fid] = $0; onFieldChanged?() } + ), + onPhotoSelected: { path in onPhotoSelected?(path, field) } + ) + .frame( + maxWidth: .infinity, + minHeight: Self.stackedMinH[ftype] ?? 0, + alignment: .topLeading + ) + } + } + .padding(Self.cardPadding) + .frame(maxWidth: .infinity, alignment: .topLeading) } // ── Derived cell width from current containerWidth ──────────────────── diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index 0524470..b552a84 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -668,6 +668,8 @@ struct ReadOnlyGridFormView: View { let schema: [[String: Any]] let formValues: [String: String] + @Environment(\.horizontalSizeClass) private var hSizeClass + // ── Field visibility filtering ──────────────────────────────────────── // A row group: all visible fields that share the same original `row`. @@ -767,9 +769,13 @@ struct ReadOnlyGridFormView: View { .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) { ForEach(visibleRowGroups.indices, id: \.self) { idx in - rowView(visibleRowGroups[idx]) + if hSizeClass == .compact { + stackedRowView(visibleRowGroups[idx]) + } else { + rowView(visibleRowGroups[idx]) + } } } .padding(12) @@ -782,6 +788,11 @@ struct ReadOnlyGridFormView: View { // Render one row as a GeometryReader-based HStack so each field // occupies exactly (colSpan/12) of the available width, and leading // space before col > 1 is filled with a transparent spacer. + // + // On a narrow screen the same 12-column division that works on iPad + // leaves each field a few dozen points wide inside a fixed 36 pt row, so + // labels and values collide. Below `minGridWidth` each field gets its own + // full-width line at its natural height instead. @ViewBuilder private func rowView(_ group: RowGroup) -> some View { GeometryReader { geo in @@ -808,6 +819,24 @@ struct ReadOnlyGridFormView: View { .frame(height: rowHeight(group)) } + /// Stacked equivalent of `rowView` for narrow screens: no proportional + /// widths, no fixed row height, so nothing can overlap. + @ViewBuilder + private func stackedRowView(_ group: RowGroup) -> some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(group.fields.indices, id: \.self) { i in + let f = group.fields[i] + let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" + let value = formValues[fid] ?? "" + let ftype = f["type"] as? String ?? "text" + let label = f["label"] as? String ?? "" + + ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + // Row height: fixed 36pt for most fields; taller for section headers. private func rowHeight(_ group: RowGroup) -> CGFloat { let hasSection = group.fields.contains { ($0["type"] as? String) == "section" } From dac7e6c597c954e3bd5f5208ff8f33ba245b8c70 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 30 Jul 2026 16:16:15 -0400 Subject: [PATCH 6/8] Jul 30 - Update iPad - inspector can re-inspect and create follow-up --- JanitorialQC/API/APIClient.swift | 50 +++++ JanitorialQC/API/APIModels.swift | 22 ++ JanitorialQC/CLAUDE.md | 25 ++- JanitorialQC/Info.plist | 5 + JanitorialQC/JanitorialQCApp.swift | 1 + .../Models/LocalFollowUpRequest.swift | 181 ++++++++++++++++ .../Models/LocalScheduledInspection.swift | 12 ++ JanitorialQC/Sync/SyncManager.swift | 60 ++++++ JanitorialQC/Utils/Constants.swift | 2 +- .../Views/Dashboard/DashboardView.swift | 54 ++++- .../Dashboard/ExecuteInspectionView.swift | 29 +++ .../Dashboard/FollowUpRequestsView.swift | 188 ++++++++++++++++ .../Views/Dashboard/MyInspectionsView.swift | 47 +++- .../Dashboard/ScheduledInspectionsView.swift | 6 + .../Views/Dashboard/StartInspectionView.swift | 95 +++++++- .../Inspection/InspectionHistoryView.swift | 203 +++++++++++++++++- 16 files changed, 961 insertions(+), 19 deletions(-) create mode 100644 JanitorialQC/Models/LocalFollowUpRequest.swift create mode 100644 JanitorialQC/Views/Dashboard/FollowUpRequestsView.swift diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index da1ebbe..c0807b9 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -429,6 +429,56 @@ actor APIClient { return result.scheduled } + /// Plan a follow-up re-inspection of `parentInspectionId` for `dueDate` + /// (phase45) — the deferred twin of "Re-inspect Now" in history detail. + /// + /// Only the parent and the date are sent: the server derives facility, + /// template and assignee from the parent inspection, so a follow-up can + /// only ever target the thing it is a follow-up of. The schedule it creates + /// carries `parent_inspection_id`, which the inspection started from it + /// inherits — that is what makes the eventual run a linked re-inspection. + /// + /// Idempotent server-side: retrying re-dates the existing active follow-up + /// for this parent instead of creating a second one. + /// + /// `dueDate` must be formatted `yyyy-MM-dd`; the server rejects a past date. + func createScheduledFollowUp( + parentInspectionId: Int, + dueDate: String, + notes: String? + ) async throws -> APIScheduledInspection { + var body: [String: Any] = [ + "parent_inspection_id": parentInspectionId, + "due_date": dueDate, + ] + // Raw snake_case body keys — JSONSerialization applies no key strategy + // (rule 65). + if let n = notes?.trimmingCharacters(in: .whitespacesAndNewlines), !n.isEmpty { + body["notes"] = n + } + let result: APIScheduledFollowUpResponseData = try await post( + "/api/v1/scheduled-inspections/follow-up", body: body + ) + return result.scheduled + } + + // ── Follow-up Requests ──────────────────────────────────────────────── + + /// Inspections a director/admin has flagged as needing a follow-up. + /// + /// Same endpoint and response shape as `fetchInspectionHistory`, but with + /// the `follow_up_required` filter so the server returns the complete + /// outstanding set rather than the recent page history shows. The limit is + /// the endpoint's maximum for the same reason — this list drives actionable + /// work, and a follow-up raised on a months-old inspection must still + /// appear. Inspector-scoped server-side. + func fetchFollowUpRequests() async throws -> [APIInspectionSummary] { + let result: InspectionHistoryResponseData = try await request( + "/api/v1/inspections?follow_up_required=true&limit=200" + ) + return result.inspections + } + // ── Issue Handler ("Handled By") ────────────────────────────────────── /// Set who handles an issue. `details` carries any of the optional diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 8b3b194..c86dcec 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -658,6 +658,11 @@ struct APIScheduledInspection: Decodable, Identifiable, Sendable { let nextDueDate: String? // ISO date "YYYY-MM-DD" let isOverdue: Bool let notes: String? + /// Set when this schedule is a planned follow-up of a specific inspection + /// (phase45, "Schedule Follow-up"). Carried onto the inspection started + /// from it so the run lands as a linked re-inspection. Nil for an ordinary + /// schedule, and on servers older than phase45. + let parentInspectionId: Int? nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -672,10 +677,12 @@ struct APIScheduledInspection: Decodable, Identifiable, Sendable { nextDueDate = try? c.decode(String.self, forKey: .nextDueDate) isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false notes = try? c.decode(String.self, forKey: .notes) + parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId) } private enum CodingKeys: String, CodingKey { case id, facilityId, facilityName, templateId, templateName case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes + case parentInspectionId } } @@ -695,6 +702,21 @@ struct APIScheduledInspectionsResponseData: Decodable, Sendable { private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset } } +/// Response of `POST /api/v1/scheduled-inspections/follow-up` (phase45). +/// `created` is false when the server re-dated an existing follow-up for the +/// same parent instead of adding a second one. +struct APIScheduledFollowUpResponseData: Decodable, Sendable { + let scheduled: APIScheduledInspection + let created: Bool + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + scheduled = try c.decode(APIScheduledInspection.self, forKey: .scheduled) + created = (try? c.decode(Bool.self, forKey: .created)) ?? true + } + private enum CodingKeys: String, CodingKey { case scheduled, created } +} + // ── Dashboard Stats (Phase B) ───────────────────────────────────────────────── struct APIDashboardStats: Decodable, Sendable { diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index 41491b7..62b1fe9 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -195,7 +195,8 @@ PendingPhoto.self, SyncQueueEntry.self | `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) | | `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) | | `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) | -| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` | +| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` | +| `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` | | `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` | | `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` | @@ -252,7 +253,9 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e 5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response). -6. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor. +6. **`pullFollowUpRequests`** — fetches `GET /api/v1/inspections?follow_up_required=true`. Upserts `LocalFollowUpRequest` by `serverId`, deletes rows the server no longer returns, and mirrors `followUpRequired`/`followUpNote` onto the matching `LocalInspection` so the history badge agrees with the card. Best-effort — never blocks the pipeline. Runs after `processInspectionQueue`, so the section clears on the same sync that submits the re-inspection. + +7. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor. ### Server-pulled issue identification @@ -310,6 +313,8 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas | `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor | | `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server | | `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` | +| `createScheduledFollowUp` | `POST /api/v1/scheduled-inspections/follow-up` | Plans a follow-up re-inspection for a later date (phase45). Body is only `parent_inspection_id` + `due_date` (`yyyy-MM-dd`) + optional `notes` — the server derives facility/template/assignee from the parent. Idempotent: a retry re-dates the existing active follow-up. Inspector-writable (deliberate divergence — the web is `@project_manager_required`). Online-only; see rule 80 | +| `fetchFollowUpRequests` | `GET /api/v1/inspections?follow_up_required=true&limit=200` | Outstanding follow-up requests; same response shape as `fetchInspectionHistory`. Limit is the endpoint max on purpose — a follow-up raised on a months-old inspection must still appear. Pulled into `LocalFollowUpRequest`. See rule 78 for what the server-side filter must mean | | `updateIssueHandler` | `PATCH /api/v1/issues//handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) | ### `APIAssignedIssue` fields @@ -467,6 +472,19 @@ else { continue } `CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`. +The **Follow-up Requested** card / section (July 2026) is the second trigger, and reaches the same view with `parentServerId` plus `preFillFollowUpNote` and `preFillParentFormDataJSON`. + +**History detail** (`HistoryDetailView`) is the third and fourth: a **Re-inspect Now** toolbar button (immediate, passing this response's own answers as `preFillParentFormDataJSON` — history is served from the API, so the parent is usually not local) and **Schedule Follow-up**, which plans it for a later date via `createScheduledFollowUp`. The scheduled row then starts as a linked re-inspection because it carries `parentInspectionServerId`. See rule 80. + +### Parent pre-fill — two sources + +`StartInspectionView.startInspection()` copies the parent's answers forward, excluding `rating`, `pass_fail`, `image`, `signature` so every scoreable item is re-evaluated fresh and the parent's photos stay with the parent. This mirrors the web's `inspections.execute` prefill. + +`resolvedParentFormData()` resolves the source in order: + +1. The local `LocalInspection` with a matching `serverId` — the `CompletedInspectionView` path, where the inspector just finished it on this iPad. Used only when it actually holds values, so an empty local shell can't shadow source 2. +2. `preFillParentFormDataJSON` — snapshotted from the server onto `LocalFollowUpRequest.parentFormDataJSON` at pull time. This is the follow-up-request path. See rule 79. + ### followUpRequired clearing — three points 1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`. @@ -706,6 +724,9 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 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. | | 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). | | 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. | +| 78 | **"Outstanding follow-up" is three conditions, not one — `follow_up_required` alone is not the definition** | Every web surface (`inspections.list` / `reports` `status_filter == 'follow_up'`, `stats.pending_followups`) means **flagged AND `status == 'completed'` AND `~follow_ups.any()`**. The reason the third clause exists: the **web execute route never clears `follow_up_required` on the parent** — it only stops *listing* the parent once a child re-inspection exists. (The mobile POST path *does* clear the parent flag, `app/api/inspections.py`, so only web-completed re-inspections leave a stale flag.) The first cut of the API's `?follow_up_required=true` filter matched the flag alone, which would have returned follow-ups already satisfied on the web — and on the iPad those rows are **undismissable**: `pullFollowUpRequests()` keeps receiving them, `update(from:)` deliberately resets `fulfilledLocally = false` (the server is authoritative), so FOLLOW-UP REQUESTED would never clear and the only way out is a duplicate re-inspection. Fixed server-side so one definition serves every client. Never re-narrow this filter to the bare flag, and never "fix" a stuck row on the client — `fulfilledLocally` is a display flag, not state. | +| 79 | **A re-inspection's parent is usually NOT on the device — prefill must fall back to the cached snapshot, and never prefill without the template schema** | `startInspection()`'s prefill originally matched only a local `LocalInspection` by `serverId`. That works for `CompletedInspectionView` (the inspector just finished it here) but **not for a follow-up raised on the web**: that parent synced long ago and is routinely absent (reinstall, second iPad, follow-up raised weeks later — the same premise `LocalFollowUpRequest` exists for). The lookup found nothing, the whole block silently no-opped, and the form opened blank where the web pre-fills it. Fix: `LocalFollowUpRequest.parentFormDataJSON` caches the parent's answers at pull time — free, because `GET /api/v1/inspections` already returns `form_data` on every row via `_inspection_payload`, so there is no extra request and prefill works offline. Store `formDataRaw.mapValues(\.anyValue)`, **not** `formValues`: the latter joins arrays into `"a, b"`, which would be written back as one bogus string. Second trap, only reachable once prefill actually runs: the exclude set is derived from the template schema, so an unresolved schema (`?? []`) yields **no exclusions and copies everything** — including the parent's `image` paths, attaching its photos as this inspection's evidence. Guard on `!schema.isEmpty` and copy nothing instead. | +| 80 | **"Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it** | History detail (`HistoryDetailView`) carries three toolbar actions: **Re-inspect Now** (immediate, opens the linked re-inspection), **Schedule Follow-up** (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a `ScheduledInspection` row that only the server can create — there is no local record to queue, so the button is `.disabled(!sync.isOnline)` and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: `pullScheduledInspections()` deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is `scheduled_inspections.parent_inspection_id` (phase45): both start paths inherit it onto the inspection (`ScheduledStartTarget.parentServerId` on iPad, the web's `scheduled_inspections.start`), **and** the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's `@project_manager_required`, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. | --- diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist index b9ecf73..ba2eaea 100644 --- a/JanitorialQC/Info.plist +++ b/JanitorialQC/Info.plist @@ -17,6 +17,11 @@ com.jqc.sync + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIBackgroundModes processing diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index ebd886b..972da88 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -101,6 +101,7 @@ struct JanitorialQCApp: App { LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self, + LocalFollowUpRequest.self, PendingPhoto.self, SyncQueueEntry.self, ], isUndoEnabled: false) { result in diff --git a/JanitorialQC/Models/LocalFollowUpRequest.swift b/JanitorialQC/Models/LocalFollowUpRequest.swift new file mode 100644 index 0000000..c31c9e9 --- /dev/null +++ b/JanitorialQC/Models/LocalFollowUpRequest.swift @@ -0,0 +1,181 @@ +// Models/LocalFollowUpRequest.swift +// --------------------------------- +// SwiftData model for follow-up requests raised by a director/admin on the web +// app against an inspection this inspector already completed. +// +// Read-only reference data pulled from the server +// (GET /api/v1/inspections?follow_up_required=true) and refreshed by +// SyncManager.pullFollowUpRequests() — never created or mutated on device. +// Surfaced in the "Follow-up Requested" section on the Dashboard and My +// Inspections. Tapping "Re-inspect" opens the normal new-inspection flow with +// the facility + template preselected and `parentServerId` set, so the +// submission lands as a linked re-inspection. +// +// WHY THIS EXISTS AS ITS OWN CACHE, rather than reading followUpRequired off +// LocalInspection: the flag is set on the WEB, after the inspection has already +// synced. By then the local copy either shows `status == "synced"` (filtered out +// of every My Inspections @Query) or is not on this device at all — a reinstall, +// a second iPad, or a follow-up raised weeks later all leave nothing to badge. +// The request has to be pulled as its own work item to be actionable. +// +// Follows LocalScheduledInspection field-for-field: a `.unique` serverId WITHOUT +// an inline default (a default on the unique key breaks @Model's PersistentModel +// conformance) and a full init(from:)/update(from:) pair. + +import Foundation +import SwiftData + +@Model +final class LocalFollowUpRequest { + + /// Server ID of the flagged (parent) Inspection — stable unique identity, + /// and the value passed as `parentServerId` when the re-inspection starts. + @Attribute(.unique) var serverId: Int + + var facilityServerId: Int + var facilityName: String + var templateServerId: Int + var templateName: String + + /// Score the flagged inspection came back with, when it has one. Shown on + /// the row: the reason a follow-up was raised is usually the low score. + var overallScore: Double? + + /// Raw server date string of the original inspection — sortable (ISO strings + /// sort chronologically) and the source for the parsed `inspectedOn`. + var inspectionDateString: String + + /// The director's note explaining what the follow-up should address. + /// Stored raw; read through `note` for the normalised form. + var followUpNote: String? + + /// Set on device the moment a re-inspection of this request is submitted, so + /// the FOLLOW-UP REQUESTED lists hide the row immediately — online or + /// offline — without waiting for the round trip. + /// + /// Purely a display flag, and the exact counterpart of + /// `LocalScheduledInspection.fulfilledLocally` (see that file for the full + /// rationale). The lifecycle stays server-driven: the server clears + /// `follow_up_required` when the linked re-inspection arrives, the next pull + /// stops returning the row, and it is deleted. If the submission never + /// lands, the server still reports the flag and the row comes back — + /// self-healing. + /// + /// Non-optional with an inline default, so SwiftData migrates lightweight + /// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall. + var fulfilledLocally: Bool = false + + /// The flagged inspection's answers, JSON-encoded `[String: String]`, cached + /// so the re-inspection can pre-fill from them. + /// + /// WHY CACHED HERE rather than read from the parent at start time: the + /// parent `LocalInspection` is usually **not on this device**. A follow-up is + /// raised on the web after the inspection already synced, so by then the + /// local copy may be long gone (reinstall, a second iPad, a follow-up raised + /// weeks later) — the same reason this model exists at all. The prefill in + /// `StartInspectionView` matched on a local parent only, found nothing, and + /// silently produced a blank form, unlike the web. + /// + /// Free to carry: `GET /api/v1/inspections` already returns `form_data` on + /// every row (`_inspection_payload`), so this costs no extra request and the + /// prefill works offline — the values are captured at pull time. + /// + /// Non-optional with an inline default so SwiftData migrates lightweight + /// (CLAUDE.md rule 12); `"{}"` decodes to an empty dictionary. + var parentFormDataJSON: String = "{}" + + /// Last time this row was refreshed from the server pull. + var updatedAt: Date + + /// The follow-up note, normalised: 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 note: String? { + guard let t = followUpNote?.trimmingCharacters(in: .whitespacesAndNewlines), + !t.isEmpty + else { return nil } + return t + } + + /// Parsed inspection date for display. Computed properties are not persisted + /// by SwiftData; sort on `inspectionDateString` (not this) in @Query. + /// + /// Parses the leading `yyyy-MM-dd` rather than the whole timestamp on + /// purpose. `SyncManager.isoFormatter` is fixed at `yyyy-MM-dd'T'HH:mm:ss` + /// and returns nil the moment the server includes fractional seconds — + /// which it does whenever the column carries microseconds (SQLite keeps + /// them; MySQL truncates by default), so the same field parses on one + /// deployment and not another. Only the day is displayed here, so taking the + /// date prefix sidesteps the whole variation. + var inspectedOn: Date? { + guard inspectionDateString.count >= 10 else { return nil } + return Self.dateOnlyFormatter.date(from: String(inspectionDateString.prefix(10))) + } + + private static let dateOnlyFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + init(from api: APIInspectionSummary) { + self.serverId = api.id + self.facilityServerId = api.facilityId + self.facilityName = api.facilityName + self.templateServerId = api.templateId + self.templateName = api.templateName + self.overallScore = api.overallScore + self.inspectionDateString = api.inspectionDate ?? "" + self.followUpNote = api.followUpNote + self.fulfilledLocally = false + self.parentFormDataJSON = Self.encode(api) + self.updatedAt = Date() + } + + /// Serialise the parent's answers for storage. + /// + /// Uses the raw `formDataRaw` values rather than the flattened `formValues` + /// so the structure survives the round trip exactly as the web's prefill + /// copies it — an array field stays an array instead of being joined into + /// `"a, b"`, which would be written back as a single bogus string. + /// `JSONValue.anyValue` yields only JSON-serialisable types (NSNull for + /// null), so `JSONSerialization` accepts the result. + /// + /// Returns `"{}"` on failure so the property is always valid JSON and + /// `parentFormData` can decode it without a special case. + private static func encode(_ api: APIInspectionSummary) -> String { + let raw = api.formDataRaw.mapValues(\.anyValue) + guard JSONSerialization.isValidJSONObject(raw), + let data = try? JSONSerialization.data(withJSONObject: raw), + let str = String(data: data, encoding: .utf8) + else { return "{}" } + return str + } + + /// The flagged inspection's answers, decoded for prefill. Typed `[String: Any]` + /// to match `LocalInspection.formData`, which is what the value is copied into. + var parentFormData: [String: Any] { + guard let data = parentFormDataJSON.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return dict + } + + func update(from api: APIInspectionSummary) { + self.facilityServerId = api.facilityId + self.facilityName = api.facilityName + self.templateServerId = api.templateId + self.templateName = api.templateName + self.overallScore = api.overallScore + self.inspectionDateString = api.inspectionDate ?? "" + self.followUpNote = api.followUpNote + self.parentFormDataJSON = Self.encode(api) + // The server is authoritative. Being returned by the pull at all means + // the follow-up is still outstanding, so any local "just did it" flag is + // stale by definition — a re-inspection that never synced, or one the + // server rejected. + self.fulfilledLocally = false + self.updatedAt = Date() + } +} diff --git a/JanitorialQC/Models/LocalScheduledInspection.swift b/JanitorialQC/Models/LocalScheduledInspection.swift index 38f6d39..82ca14c 100644 --- a/JanitorialQC/Models/LocalScheduledInspection.swift +++ b/JanitorialQC/Models/LocalScheduledInspection.swift @@ -38,6 +38,16 @@ final class LocalScheduledInspection { var isOverdue: Bool var notes: String? + /// Server ID of the inspection this schedule is a planned follow-up of + /// (phase45). Carried onto the `LocalInspection` as `parentServerId` when + /// the inspector starts it, so the run lands as a linked re-inspection + /// rather than an ordinary scheduled one. + /// + /// Optional, so SwiftData migrates lightweight without a plan (rule 46); + /// nil for an ordinary schedule and for every row pulled from a + /// pre-phase45 server. + var parentInspectionServerId: Int? + /// Set on device the moment an inspection fulfilling this schedule is /// submitted, so the SCHEDULED lists hide the row immediately — online or /// offline — without waiting for the round trip. @@ -105,6 +115,7 @@ final class LocalScheduledInspection { self.dueDateString = api.nextDueDate ?? "" self.isOverdue = api.isOverdue self.notes = api.notes + self.parentInspectionServerId = api.parentInspectionId self.fulfilledLocally = false self.updatedAt = Date() } @@ -120,6 +131,7 @@ final class LocalScheduledInspection { self.dueDateString = api.nextDueDate ?? "" self.isOverdue = api.isOverdue self.notes = api.notes + self.parentInspectionServerId = api.parentInspectionId // The server is authoritative. Being returned by the pull at all means // this schedule is live again — for a recurring one, on its NEXT // occurrence — so any local "just did it" flag is stale by definition. diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index f959269..7484bb2 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -278,6 +278,7 @@ class SyncManager: ObservableObject { await pullReferenceData() await pullAssignedIssues(context: context) await pullScheduledInspections(context: context) + await pullFollowUpRequests(context: context) // Poll notifications immediately on every sync rather than waiting // for the 60-second timer — ensures the inspector sees assignments @@ -918,6 +919,65 @@ class SyncManager: ObservableObject { } } + // ── Follow-up Requests ──────────────────────────────────────────────── + // Read-only pull of inspections a director flagged for follow-up, for the + // Dashboard and My Inspections "Follow-up Requested" section. Upsert by + // serverId, then delete rows the server no longer returns (the follow-up was + // fulfilled by a linked re-inspection, or the director cleared the flag). + // Best-effort — never blocks the pipeline. + + func pullFollowUpRequests(context: ModelContext) async { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + do { + let apiRows = try await APIClient.shared.fetchFollowUpRequests() + + // Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3). + let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] + var byServerId: [Int: LocalFollowUpRequest] = [:] + for row in allLocal { byServerId[row.serverId] = row } + + for api in apiRows { + if let existing = byServerId[api.id] { + existing.update(from: api) + } else { + context.insert(LocalFollowUpRequest(from: api)) + } + } + + // Delete rows the server no longer returns. + let returnedIds = Set(apiRows.map { $0.id }) + for row in allLocal where !returnedIds.contains(row.serverId) { + context.delete(row) + } + + // Keep the local copy of the flagged inspection in step, so the + // follow-up badge in My Inspections / history detail agrees with the + // card without waiting for the inspector to open that detail view + // (which was previously the only thing that wrote these fields). + var noteByServerId: [Int: String] = [:] + for api in apiRows { + if let note = api.followUpNote { noteByServerId[api.id] = note } + } + for local in (try? context.fetch(FetchDescriptor())) ?? [] { + guard let sid = local.serverId else { continue } + if returnedIds.contains(sid) { + local.followUpRequired = true + local.followUpNote = noteByServerId[sid] + } else if local.followUpRequired { + local.followUpRequired = false + local.followUpNote = nil + } + } + + try? context.save() + + } catch APIError.notAuthenticated { + // Let AuthManager handle session expiry + } catch { + // Non-fatal — stale follow-up rows stay visible until next pull + } + } + // ── Dashboard Stats ─────────────────────────────────────────────────── // Best-effort fetch — a network failure silently leaves dashboardStats nil // so the UI falls back to a placeholder card. Never blocks the sync pipeline. diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift index 371d0fc..529d711 100644 --- a/JanitorialQC/Utils/Constants.swift +++ b/JanitorialQC/Utils/Constants.swift @@ -8,7 +8,7 @@ import Foundation /// The two known JQC servers the inspector can connect to. nonisolated enum ServerOption: String, CaseIterable, Sendable { - case primary = "https://jqc.ltservicesinc.com" + case primary = "http://127.0.0.1:5055" case secondary = "https://jqc1.ltservicesinc.com" var displayName: String { diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index ef16b1d..cda6f8b 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -42,6 +42,19 @@ struct DashboardView: View { order: .reverse ) private var myInspections: [LocalInspection] + /// Outstanding follow-up requests, counted into the My Inspections badge so + /// the inspector sees there is work waiting from any tab — the same reason + /// in-progress inspections are counted there. + @Query private var followUpRequests: [LocalFollowUpRequest] + + /// Badge count for My Inspections: in-progress work plus outstanding + /// follow-ups. `fulfilledLocally` rows are excluded in Swift, not in the + /// @Query predicate (CLAUDE.md rule 3), so the badge drops the instant a + /// re-inspection is submitted. + private var myInspectionsBadgeCount: Int { + myInspections.count + followUpRequests.filter { !$0.fulfilledLocally }.count + } + @State private var selectedTab: SidebarTab = .dashboard /// Each sidebar tap refreshes the UUID for that tab, forcing its /// NavigationStack to be destroyed and recreated — even when the tab @@ -181,8 +194,8 @@ struct DashboardView: View { Label("My Inspections", systemImage: "checklist") .foregroundStyle(tint) Spacer() - if !myInspections.isEmpty { - Text("\(myInspections.count)") + if myInspectionsBadgeCount > 0 { + Text("\(myInspectionsBadgeCount)") .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.blue.opacity(0.15)) @@ -332,6 +345,10 @@ struct DashboardStatsView: View { /// inspection empties its @Query while the start form is still presented. @State private var scheduledStartTarget: ScheduledStartTarget? = nil + /// The follow-up request tapped in FollowUpRequestsCard. Held here for the + /// same reason as `scheduledStartTarget` — see the covers at the bottom. + @State private var followUpStartTarget: FollowUpStartTarget? = nil + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -341,6 +358,15 @@ struct DashboardStatsView: View { DraftResumeBanner(drafts: draftInspections, context: context) } + // ── Follow-up Requests ───────────────────────────────────── + // Re-inspections a director asked for. Ranked above SCHEDULED: + // a follow-up is remedial work on a facility that already failed + // once, so it is the more urgent of the two. Self-hides when + // there are none. Tap a row to start the linked re-inspection. + FollowUpRequestsCard(onStart: { target in + followUpStartTarget = target + }) + // ── Scheduled Inspections (phase36) ──────────────────────── // Planned/recurring assignments for this inspector. Self-hides // when there are none. Tap a row to start it (facility + @@ -484,10 +510,34 @@ struct DashboardStatsView: View { StartInspectionView( preFillTemplateId: t.templateServerId, preFillFacilityId: t.facilityServerId, + // phase45 — nil for an ordinary schedule; set when this row is a + // planned follow-up, which makes the run a linked re-inspection. + // Must precede preFillScheduleId: argument order follows the + // property declaration order in StartInspectionView. + parentServerId: t.parentServerId, preFillScheduleId: t.id, preFillScheduleInstructions: t.instructions ) } + // Start cover for a tapped follow-up request. Owned here for the same + // reason as the scheduled cover above: FollowUpRequestsCard self-hides + // the instant its last row is invalidated at submit, which is exactly + // when this cover is on screen. + // + // `parentServerId` is what makes this a re-inspection rather than a + // fresh one — the server reads it to clear follow_up_required on the + // flagged inspection. `parentLocalId` stays nil: the parent synced long + // ago (that is how it got flagged), so serverId is the reliable handle, + // and clearParentFollowUpFlag()'s fallback-1 matches on it. + .fullScreenCover(item: $followUpStartTarget) { t in + StartInspectionView( + preFillTemplateId: t.templateServerId, + preFillFacilityId: t.facilityServerId, + parentServerId: t.id, + preFillFollowUpNote: t.note, + preFillParentFormDataJSON: t.parentFormDataJSON + ) + } } // ── Helpers ──────────────────────────────────────────────────────────── diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index be25f0e..24ea0bb 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -648,6 +648,13 @@ struct ExecuteInspectionView: View { // regardless of connectivity or sync timing. clearParentFollowUpFlag() + // ── Drop the cached follow-up request row ───────────────────────── + // Same immediacy as above, for the other surface: the FOLLOW-UP + // REQUESTED card on the Dashboard and My Inspections reads its own + // pulled cache, not LocalInspection, so clearing the flag above is not + // enough to make the row disappear. + fulfillFollowUpRequest() + // ── 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 @@ -719,6 +726,28 @@ struct ExecuteInspectionView: View { } } + /// Invalidate the cached follow-up request this submission satisfies, so the + /// FOLLOW-UP REQUESTED card and section clear the moment Submit is tapped — + /// online or offline — rather than waiting for the round trip. + /// + /// Matches on `parentServerId` alone. Unlike the schedule fallback there is + /// no facility+template guess here: a request is keyed by the exact + /// inspection it was raised against, and that id is set whenever the run was + /// launched from a follow-up row or from CompletedInspectionView's banner. + /// An ad-hoc inspection of the same facility is genuinely not the follow-up + /// the director asked for, and must not clear it. + /// + /// The row is flagged, not deleted, for the same reason as + /// `LocalScheduledInspection.fulfilledLocally`: the server is authoritative, + /// and `pullFollowUpRequests()` deletes the row once the flag actually + /// clears — or brings it back if the submission never landed. + private func fulfillFollowUpRequest() { + guard let sid = inspection.parentServerId else { return } + // Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3). + let all = (try? context.fetch(FetchDescriptor())) ?? [] + all.first { $0.serverId == sid }?.fulfilledLocally = true + } + // ── Scheduled inspection fulfilment ─────────────────────────────────── /// Link this submission to the schedule it satisfies, then drop the cached diff --git a/JanitorialQC/Views/Dashboard/FollowUpRequestsView.swift b/JanitorialQC/Views/Dashboard/FollowUpRequestsView.swift new file mode 100644 index 0000000..bc47ef7 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/FollowUpRequestsView.swift @@ -0,0 +1,188 @@ +// Views/Dashboard/FollowUpRequestsView.swift +// ------------------------------------------ +// Displays inspections a director/admin flagged as needing a follow-up, pulled +// read-only from GET /api/v1/inspections?follow_up_required=true by +// SyncManager.pullFollowUpRequests(). +// +// Deliberately built as the twin of ScheduledInspectionsView: a follow-up +// request is assigned work the inspector must recognise and act on, exactly +// like a scheduled assignment, so it gets the same two surfaces and the same +// ownership rules. +// +// Two consumers share one FollowUpRow: +// • FollowUpRequestsCard — 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 "Follow-up Requested" List section +// inline, reusing FollowUpRow, with the start cover on the enclosing Group. +// Both self-hide when there are none, and present StartInspectionView with the +// facility + template preselected AND `parentServerId` set, so the submission +// lands as a linked re-inspection — the same path CompletedInspectionView's +// "Start Re-inspection" banner has always used. +// Both cover owners are views that outlive the rows themselves — submitting the +// last follow-up 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 lifecycle stays server-driven: the re-inspection carries +// `parent_inspection_id` and the server clears `follow_up_required` on arrival. +// ExecuteInspectionView only invalidates the local cache row; +// pullFollowUpRequests() re-reads the authoritative state. + +import SwiftUI +import SwiftData + +// MARK: - Start target snapshot + +/// Plain-value snapshot of the tapped request, used as the `.fullScreenCover` +/// item instead of the `LocalFollowUpRequest` itself. +/// +/// The model object is unsafe to hold across the presentation: the cached row is +/// invalidated while the cover is still on screen — by `fulfillFollowUpRequest()` +/// the instant Submit is tapped, and deleted by `pullFollowUpRequests()` 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. (Same reasoning as +/// `ScheduledStartTarget`.) +struct FollowUpStartTarget: Identifiable { + /// Flagged inspection's `serverId` — the identity for `.fullScreenCover(item:)` + /// and the `parentServerId` the re-inspection is linked to. + let id: Int + let templateServerId: Int + let facilityServerId: Int + /// The director's note explaining what the follow-up should address. + let note: String? + /// The flagged inspection's answers, JSON-encoded, carried so the + /// re-inspection can pre-fill from them. Snapshotted here for the same + /// reason as every other field: the row it came from is invalidated while + /// the start form is still on screen. + let parentFormDataJSON: String + + init(_ request: LocalFollowUpRequest) { + self.id = request.serverId + self.templateServerId = request.templateServerId + self.facilityServerId = request.facilityServerId + self.note = request.note + self.parentFormDataJSON = request.parentFormDataJSON + } +} + +// MARK: - Shared row + +struct FollowUpRow: View { + let request: LocalFollowUpRequest + + private var inspectedText: String { + if let d = request.inspectedOn { + return d.formatted(date: .abbreviated, time: .omitted) + } + return request.inspectionDateString.isEmpty ? "—" : request.inspectionDateString + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.arrow.circlepath") + .font(.title3) + .foregroundStyle(.orange) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 3) { + Text(request.templateName.isEmpty ? "Inspection" : request.templateName) + .font(.callout.bold()) + Text(request.facilityName.isEmpty ? "Facility" : request.facilityName) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Text("Follow-up") + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.orange.opacity(0.15)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + Text("Inspected \(inspectedText)") + .font(.caption2) + .foregroundStyle(.secondary) + // The score is usually why the follow-up was raised, so it + // is the one number worth showing before the tap. + if let score = request.overallScore { + Text("· \(String(format: "%.1f%%", score))") + .font(.caption2) + .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) + } + } + + // Note 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. Mirrors the + // instructions preview on ScheduledRow. + if let note = request.note { + HStack(alignment: .top, spacing: 4) { + Image(systemName: "info.circle.fill") + .font(.caption2) + .foregroundStyle(.orange) + Text(note) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.top, 1) + } + } + + Spacer(minLength: 8) + + Label("Re-inspect", systemImage: "arrow.uturn.right.circle.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color.orange) + .clipShape(Capsule()) + } + .contentShape(Rectangle()) + } +} + +// MARK: - Dashboard card (VStack) + +struct FollowUpRequestsCard: View { + @Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward) + private var requests: [LocalFollowUpRequest] + + /// Rows still awaiting action. Filtered in Swift rather than in the @Query + /// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and + /// cleared by the next pull, so a completed follow-up leaves the card at + /// once and reappears only if the re-inspection never reached the server. + private var visible: [LocalFollowUpRequest] { + requests.filter { !$0.fulfilledLocally } + } + + /// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT + /// (`DashboardStatsView`, on its always-present ScrollView) rather than here, + /// for the same reason as `ScheduledInspectionsCard`: this card self-hides, + /// and submitting the last follow-up removes the final row while the cover is + /// still on screen. Keeping the card purely presentational also keeps the + /// empty case a true `EmptyView`, so the dashboard stack adds no spacing. + let onStart: (FollowUpStartTarget) -> Void + + var body: some View { + if !visible.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("FOLLOW-UP REQUESTED") + .font(.caption.bold()) + .foregroundStyle(.orange) + .tracking(1) + + ForEach(visible) { r in + Button { onStart(FollowUpStartTarget(r)) } label: { + FollowUpRow(request: r) + .padding(12) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + } + } + } +} diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 14079fc..3c49a67 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -24,10 +24,23 @@ struct MyInspectionsView: View { scheduledAll.filter { !$0.fulfilledLocally } } + /// Follow-up requests raised on the web — rendered as the top section, above + /// Scheduled, and counted in the empty-state decision. Sorted by the flagged + /// inspection's date (ISO strings sort chronologically), oldest first: the + /// longest-outstanding request is the one to clear next. + @Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward) + private var followUpsAll: [LocalFollowUpRequest] + + /// Rows still awaiting action — see FollowUpRequestsCard.visible. + private var followUpsVisible: [LocalFollowUpRequest] { + followUpsAll.filter { !$0.fulfilledLocally } + } + @Environment(\.modelContext) private var context @State private var showNewInspection = false @State private var scheduledStartTarget: ScheduledStartTarget? + @State private var followUpStartTarget: FollowUpStartTarget? // Deletion confirmation state @State private var pendingDelete: LocalInspection? @@ -35,7 +48,7 @@ struct MyInspectionsView: View { var body: some View { Group { - if inspections.isEmpty && scheduledVisible.isEmpty { + if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty { ContentUnavailableView( "No Inspections", systemImage: "checklist", @@ -43,6 +56,20 @@ struct MyInspectionsView: View { ) } else { List { + // Follow-up requests — self-hides when empty. First section: + // remedial work on a facility that already failed once + // outranks a routine scheduled visit. + if !followUpsVisible.isEmpty { + Section("Follow-up Requested") { + ForEach(followUpsVisible) { r in + Button { followUpStartTarget = FollowUpStartTarget(r) } label: { + FollowUpRow(request: r) + } + .buttonStyle(.plain) + } + } + } + // Scheduled assignments (phase36) — self-hides when empty. if !scheduledVisible.isEmpty { Section("Scheduled") { @@ -87,10 +114,28 @@ struct MyInspectionsView: View { StartInspectionView( preFillTemplateId: t.templateServerId, preFillFacilityId: t.facilityServerId, + // phase45 — nil for an ordinary schedule; set when this row is a + // planned follow-up, which makes the run a linked re-inspection. + // Must precede preFillScheduleId: argument order follows the + // property declaration order in StartInspectionView. + parentServerId: t.parentServerId, preFillScheduleId: t.id, preFillScheduleInstructions: t.instructions ) } + // Also on the Group, not the List — see the comment above. Submitting the + // last follow-up can flip this view to ContentUnavailableView while the + // cover is still presented. `parentServerId` is what links the run back + // to the flagged inspection; see the twin cover in DashboardStatsView. + .fullScreenCover(item: $followUpStartTarget) { t in + StartInspectionView( + preFillTemplateId: t.templateServerId, + preFillFacilityId: t.facilityServerId, + parentServerId: t.id, + preFillFollowUpNote: t.note, + preFillParentFormDataJSON: t.parentFormDataJSON + ) + } .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 e7c38de..b4fb2e0 100644 --- a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift @@ -44,12 +44,18 @@ struct ScheduledStartTarget: Identifiable { /// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the /// user-facing wording is "Instructions". let instructions: String? + /// Inspection this schedule is a planned follow-up of (phase45), or nil for + /// an ordinary schedule. Passed to `StartInspectionView` as `parentServerId` + /// so the run lands as a linked re-inspection — the whole point of + /// "Schedule Follow-up". + let parentServerId: Int? init(_ schedule: LocalScheduledInspection) { self.id = schedule.serverId self.templateServerId = schedule.templateServerId self.facilityServerId = schedule.facilityServerId self.instructions = schedule.instructions + self.parentServerId = schedule.parentInspectionServerId } } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index d356377..915b4a1 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -37,6 +37,27 @@ struct StartInspectionView: View { var parentServerId: Int? = nil var parentLocalId: String? = nil + /// The director's note explaining what the follow-up should address, set when + /// the inspector taps a row in the FOLLOW-UP REQUESTED card. Passed as a plain + /// String rather than read from SwiftData here, for the same reason + /// FollowUpStartTarget exists — the cached request row is invalidated at + /// submit while this flow is still on screen. Nil for a re-inspection the + /// inspector started themselves from CompletedInspectionView. + var preFillFollowUpNote: String? = nil + + /// The flagged inspection's answers, JSON-encoded, used to pre-fill this + /// re-inspection when the parent `LocalInspection` is not on this device. + /// + /// The local-parent lookup in `startInspection()` covers the + /// CompletedInspectionView path, where the inspector is re-inspecting + /// something they just finished on this iPad. It does **not** cover a + /// follow-up raised on the web: that parent synced long ago and is often + /// absent locally, so the lookup found nothing and the form came up blank — + /// where the web pre-fills it. Cached at pull time on + /// `LocalFollowUpRequest`, so this works offline too. Nil for every other + /// start path. + var preFillParentFormDataJSON: String? = nil + // ── Scheduled inspection launch ─────────────────────────────────────── /// Server ID of the ScheduledInspection this run fulfils, passed when the /// inspector taps Start on a scheduled row. Carried onto the LocalInspection @@ -123,6 +144,16 @@ struct StartInspectionView: View { Text("This will be linked to inspection #\(parentServerId!).") .font(.caption) .foregroundStyle(.secondary) + // What the director actually asked for. Shown + // here rather than in its own section so it + // reads as part of the request, and repeated in + // full because the card truncates it to a line. + if let note = preFillFollowUpNote, !note.isEmpty { + Text(note) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } } } .padding(.vertical, 4) @@ -307,17 +338,30 @@ struct StartInspectionView: View { // inspector doesn't re-enter static data. Scoring fields (rating, // pass_fail) and media fields (image, signature) are always left blank // so every scoreable item must be re-evaluated fresh. - if let parentId = parentServerId { - let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] - if let parent = allInspections.first(where: { $0.serverId == parentId }), - !parent.formData.isEmpty { + if parentServerId != nil { + // Prefer the local parent (the CompletedInspectionView path, where + // the inspector just finished it on this iPad); otherwise fall back + // to the snapshot cached on the follow-up request. A follow-up + // raised on the web has usually synced and been dropped locally, so + // without the fallback this whole block silently no-opped and the + // form came up blank — the bug this fixes. + let parentData = resolvedParentFormData() - // Fetch the template schema to identify field types. - // Split into two statements — avoids Xcode 26 #Predicate - // ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. - let allTemplates = (try? context.fetch(FetchDescriptor())) ?? [] - let tid = templateId - let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? [] + // Fetch the template schema to identify field types. + // Split into two statements — avoids Xcode 26 #Predicate + // ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. + let allTemplates = (try? context.fetch(FetchDescriptor())) ?? [] + let tid = templateId + let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? [] + + // The schema is what identifies which fields must NOT be carried + // over, so without it there is no safe prefill: an empty exclude set + // would copy *everything*, including the parent's `image` paths and + // its ratings — attaching the previous inspection's photos as this + // one's evidence and pre-answering the scoreable items. Copy nothing + // instead. (Unreachable in practice: the template was chosen from + // the local picker, so it is cached — this is a guard, not a case.) + if !parentData.isEmpty, !schema.isEmpty { // Build the set of field IDs that must NOT be carried over let excludeTypes: Set = ["rating", "pass_fail", "image", "signature"] @@ -330,7 +374,6 @@ struct StartInspectionView: View { } // Copy all parent values except excluded fields - let parentData = parent.formData var prefilled: [String: Any] = [:] for (key, value) in parentData { if !excludeIds.contains(key) { @@ -349,4 +392,34 @@ struct StartInspectionView: View { createdInspection = inspection navigateToExecution = true } + + /// The parent inspection's answers to pre-fill from, or `[:]` when there are + /// none to carry. + /// + /// Two sources, in order: + /// 1. The local `LocalInspection` with a matching `serverId` — the + /// re-inspection-from-history path, where the parent is on this device + /// and is the freshest copy. + /// 2. `preFillParentFormDataJSON`, snapshotted from the server at pull + /// time — the follow-up-request path, where the parent has synced and + /// is typically no longer local. + /// + /// Source 1 is checked first but only wins when it actually holds values, so + /// a stray empty local shell can't shadow a good server snapshot. + private func resolvedParentFormData() -> [String: Any] { + if let parentId = parentServerId { + // Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3). + let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] + if let parent = allInspections.first(where: { $0.serverId == parentId }) { + let localData = parent.formData + if !localData.isEmpty { return localData } + } + } + + guard let json = preFillParentFormDataJSON, + let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return dict + } } diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index b552a84..c470b6b 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -350,11 +350,32 @@ struct HistoryDetailView: View { let inspection: APIInspectionSummary @Environment(\.modelContext) private var context + @EnvironmentObject private var auth: AuthManager + @EnvironmentObject private var sync: SyncManager @State private var showReInspect = false @State private var showMailCompose = false @State private var isGeneratingPDF = false @State private var generatedPDFData: Data? = nil + // ── Schedule Follow-up (phase45) ────────────────────────────────────── + @State private var showScheduleSheet = false + /// Defaults to tomorrow: the point of this action is to plan the follow-up + /// for another day. Today is still selectable — the server allows it. + @State private var followUpDate = Calendar.current.date( + byAdding: .day, value: 1, to: Date() + ) ?? Date() + @State private var followUpNotes = "" + @State private var isSchedulingFollowUp = false + @State private var scheduleError: String? = nil + @State private var scheduleConfirmation: String? = nil + + /// Auditors are read-only everywhere else and the API rejects them (403), + /// so the two action buttons are hidden rather than shown failing. + private var canStartFollowUp: Bool { + ["admin", "director", "inspector", "project_manager"] + .contains(auth.currentUserRole) + } + // Local SwiftData copy — used only for follow-up sync-back. // Form data and schema come from the server response directly so // History works even after app reinstall or on a different device. @@ -463,6 +484,34 @@ struct HistoryDetailView: View { .navigationTitle(inspection.templateName) .navigationBarTitleDisplayMode(.inline) .toolbar { + // ── Re-inspect now ──────────────────────────────────────────── + // The immediate half of the follow-up pair. Opens the same linked + // re-inspection flow the follow-up banner has always used, but + // without waiting to be asked for one. + if canStartFollowUp { + ToolbarItem(placement: .primaryAction) { + Button { + showReInspect = true + } label: { + Label("Re-inspect Now", systemImage: "arrow.uturn.right.circle") + } + } + + // ── Schedule follow-up ──────────────────────────────────── + // The deferred half. Needs the network: it creates a schedule + // server-side rather than a local record, so unlike starting an + // inspection it cannot be queued offline. + ToolbarItem(placement: .primaryAction) { + Button { + scheduleError = nil + showScheduleSheet = true + } label: { + Label("Schedule Follow-up", systemImage: "calendar.badge.plus") + } + .disabled(!sync.isOnline) + } + } + ToolbarItem(placement: .primaryAction) { Button { Task { await prepareAndShowMail() } @@ -476,16 +525,34 @@ struct HistoryDetailView: View { .disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF) } } + .sheet(isPresented: $showScheduleSheet) { scheduleFollowUpSheet } + // Confirmation of a successful schedule. An alert rather than an inline + // banner because the sheet has already dismissed by this point. + .alert("Follow-up Scheduled", + isPresented: Binding(get: { scheduleConfirmation != nil }, + set: { if !$0 { scheduleConfirmation = nil } })) { + Button("OK") { scheduleConfirmation = nil } + } message: { + Text(scheduleConfirmation ?? "") + } .onAppear { loadLocalData() syncFollowUpToLocalCopy() } - .sheet(isPresented: $showReInspect) { + // Full-screen, not a sheet: every inspection-start flow is full-screen + // (rule 66), and this one is now reachable from the toolbar on any + // completed inspection rather than only the follow-up banner. + .fullScreenCover(isPresented: $showReInspect) { StartInspectionView( preFillTemplateId: inspection.templateId, preFillFacilityId: inspection.facilityId, parentServerId: inspection.id, - parentLocalId: inspection.mobileLocalId + parentLocalId: inspection.mobileLocalId, + // History is served from the API, so this inspection is often + // not on this device at all and the local-parent lookup finds + // nothing — the form would open blank (rule 79). The answers are + // already in this very response, so pass them straight through. + preFillParentFormDataJSON: parentFormDataJSON ) } .sheet(isPresented: $showMailCompose) { @@ -501,6 +568,138 @@ struct HistoryDetailView: View { } } + /// This inspection's answers, JSON-encoded for `StartInspectionView`'s + /// parent prefill. Raw values, not the flattened `formValues`, so an array + /// field survives as an array (rule 79). + private var parentFormDataJSON: String { + let raw = inspection.formDataRaw.mapValues(\.anyValue) + guard JSONSerialization.isValidJSONObject(raw), + let data = try? JSONSerialization.data(withJSONObject: raw), + let str = String(data: data, encoding: .utf8) + else { return "{}" } + return str + } + + // ── Schedule Follow-up sheet (phase45) ──────────────────────────────── + + /// Date + note picker for planning a follow-up re-inspection. + /// + /// Only the date and an optional note are collected: the server derives + /// facility, template and assignee from the parent inspection, so there is + /// nothing else for the inspector to get wrong. + private var scheduleFollowUpSheet: some View { + NavigationStack { + Form { + Section { + Text(inspection.templateName) + .font(.callout.bold()) + Text(inspection.facilityName) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Follow-up of Inspection #\(inspection.id)") + } + + Section { + DatePicker( + "Due Date", + selection: $followUpDate, + in: Date()..., // the server rejects a past date + displayedComponents: .date + ) + .datePickerStyle(.graphical) + } header: { + Text("When") + } footer: { + Text("The follow-up appears in Scheduled on this date, " + + "assigned to the inspector who did the original.") + } + + Section { + TextField( + "What should the follow-up address?", + text: $followUpNotes, + axis: .vertical + ) + .lineLimit(3...6) + } header: { + Text("Instructions (optional)") + } + + if let err = scheduleError { + Section { + Label(err, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Schedule Follow-up") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showScheduleSheet = false } + .disabled(isSchedulingFollowUp) + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { await submitScheduledFollowUp() } + } label: { + if isSchedulingFollowUp { + ProgressView() + } else { + Text("Schedule") + } + } + .disabled(isSchedulingFollowUp) + } + } + } + } + + /// Create the follow-up schedule on the server, then refresh so it appears + /// in the Scheduled lists without waiting for the next timed sync. + /// + /// Online-only by nature: this writes a server-side plan, not a local + /// record, so there is nothing meaningful to queue offline — the button is + /// disabled when offline and this reports any failure inline rather than + /// dismissing as if it had worked. + private func submitScheduledFollowUp() async { + isSchedulingFollowUp = true + scheduleError = nil + + let due = Self.dueDateFormatter.string(from: followUpDate) + do { + _ = try await APIClient.shared.createScheduledFollowUp( + parentInspectionId: inspection.id, + dueDate: due, + notes: followUpNotes + ) + // Pull the new schedule straight into the Scheduled section. + await sync.pullScheduledInspections(context: context) + + isSchedulingFollowUp = false + showScheduleSheet = false + followUpNotes = "" + scheduleConfirmation = + "A follow-up re-inspection of \(inspection.facilityName) is scheduled for " + + followUpDate.formatted(date: .abbreviated, time: .omitted) + "." + } catch { + isSchedulingFollowUp = false + scheduleError = (error as? APIError)?.localizedDescription + ?? "Could not schedule the follow-up. Check your connection and try again." + } + } + + /// `yyyy-MM-dd` for the API's `due_date`. Fixed POSIX locale so a non- + /// Gregorian device calendar cannot emit a date the server can't parse. + private static let dueDateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + /// Generates the PDF (fetching any server photos over the network), /// then presents the mail compose sheet with it attached. /// Photo fetches happen here, off the synchronous PDF drawing pass. From d9a6ffcca29de4a85aff1ba0c4ba98e944a085fb Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 31 Jul 2026 10:38:54 -0400 Subject: [PATCH 7/8] Jul 31 - Fix primary server (jqc) login issue --- JanitorialQC.xcodeproj/project.pbxproj | 4 ++-- JanitorialQC/Info.plist | 26 ++++++++++++++++++++++++-- JanitorialQC/Utils/Constants.swift | 2 +- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 3030da1..890fe89 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -411,7 +411,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist index ba2eaea..be45685 100644 --- a/JanitorialQC/Info.plist +++ b/JanitorialQC/Info.plist @@ -17,10 +17,32 @@ com.jqc.sync + NSAppTransportSecurity - NSAllowsArbitraryLoads - + NSExceptionDomains + + 127.0.0.1 + + NSExceptionAllowsInsecureHTTPLoads + + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + UIBackgroundModes diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift index 529d711..371d0fc 100644 --- a/JanitorialQC/Utils/Constants.swift +++ b/JanitorialQC/Utils/Constants.swift @@ -8,7 +8,7 @@ import Foundation /// The two known JQC servers the inspector can connect to. nonisolated enum ServerOption: String, CaseIterable, Sendable { - case primary = "http://127.0.0.1:5055" + case primary = "https://jqc.ltservicesinc.com" case secondary = "https://jqc1.ltservicesinc.com" var displayName: String { From 49fa39fe9844dd827d8780be3bac6ae644af5878 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 31 Jul 2026 14:40:32 -0400 Subject: [PATCH 8/8] Jul 31 - Update PhotoCapture.swift --- JanitorialQC.xcodeproj/project.pbxproj | 8 ++++---- JanitorialQC/CLAUDE.md | 2 ++ JanitorialQC/Utils/PhotoCapture.swift | 13 ++++++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 890fe89..fa0d967 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -411,7 +411,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -431,7 +431,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -474,7 +474,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.9; 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 62b1fe9..2ad84a7 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -727,6 +727,8 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 78 | **"Outstanding follow-up" is three conditions, not one — `follow_up_required` alone is not the definition** | Every web surface (`inspections.list` / `reports` `status_filter == 'follow_up'`, `stats.pending_followups`) means **flagged AND `status == 'completed'` AND `~follow_ups.any()`**. The reason the third clause exists: the **web execute route never clears `follow_up_required` on the parent** — it only stops *listing* the parent once a child re-inspection exists. (The mobile POST path *does* clear the parent flag, `app/api/inspections.py`, so only web-completed re-inspections leave a stale flag.) The first cut of the API's `?follow_up_required=true` filter matched the flag alone, which would have returned follow-ups already satisfied on the web — and on the iPad those rows are **undismissable**: `pullFollowUpRequests()` keeps receiving them, `update(from:)` deliberately resets `fulfilledLocally = false` (the server is authoritative), so FOLLOW-UP REQUESTED would never clear and the only way out is a duplicate re-inspection. Fixed server-side so one definition serves every client. Never re-narrow this filter to the bare flag, and never "fix" a stuck row on the client — `fulfilledLocally` is a display flag, not state. | | 79 | **A re-inspection's parent is usually NOT on the device — prefill must fall back to the cached snapshot, and never prefill without the template schema** | `startInspection()`'s prefill originally matched only a local `LocalInspection` by `serverId`. That works for `CompletedInspectionView` (the inspector just finished it here) but **not for a follow-up raised on the web**: that parent synced long ago and is routinely absent (reinstall, second iPad, follow-up raised weeks later — the same premise `LocalFollowUpRequest` exists for). The lookup found nothing, the whole block silently no-opped, and the form opened blank where the web pre-fills it. Fix: `LocalFollowUpRequest.parentFormDataJSON` caches the parent's answers at pull time — free, because `GET /api/v1/inspections` already returns `form_data` on every row via `_inspection_payload`, so there is no extra request and prefill works offline. Store `formDataRaw.mapValues(\.anyValue)`, **not** `formValues`: the latter joins arrays into `"a, b"`, which would be written back as one bogus string. Second trap, only reachable once prefill actually runs: the exclude set is derived from the template schema, so an unresolved schema (`?? []`) yields **no exclusions and copies everything** — including the parent's `image` paths, attaching its photos as this inspection's evidence. Guard on `!schema.isEmpty` and copy nothing instead. | | 80 | **"Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it** | History detail (`HistoryDetailView`) carries three toolbar actions: **Re-inspect Now** (immediate, opens the linked re-inspection), **Schedule Follow-up** (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a `ScheduledInspection` row that only the server can create — there is no local record to queue, so the button is `.disabled(!sync.isOnline)` and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: `pullScheduledInspections()` deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is `scheduled_inspections.parent_inspection_id` (phase45): both start paths inherit it onto the inspection (`ScheduledStartTarget.parentServerId` on iPad, the web's `scheduled_inspections.start`), **and** the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's `@project_manager_required`, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. | +| 81 | **Never commit a local-dev override — repointing `ServerOption.primary` at localhost took production login down** | July 2026: `primary` was changed from `https://jqc.ltservicesinc.com` to `http://127.0.0.1:5055` for local API work, with `NSAllowsArbitraryLoads=true` added to `Info.plist` for cleartext. Both shipped in `dac7e6c`, so the "Primary" entry in the server picker dialled a developer laptop and **every inspector failed to log in** — only the untouched secondary worked. Symptom in the device log is unmistakable and is *not* an auth problem: `NSErrorFailingURLStringKey=http://127.0.0.1:5055/...` with `Connection refused [61]`. Revert a dev override in the same session that adds it; being aware of it is not a safeguard. Prefer an override that *cannot* be committed — a debug-only scheme argument, an xcconfig, or `#if DEBUG` — over editing this shared production constant. One thing that saved us: `ServerConfig.current` validates the stored UserDefaults string through `ServerOption(rawValue:)` and falls back to `.primary`, so a stale localhost selection self-heals on update — keep that round-trip validation. ATS exceptions must be scoped to the host (`NSExceptionDomains` for `127.0.0.1`/`localhost`); `NSAllowsArbitraryLoads` disables TLS validation for the *production* servers too and is an App Store review trigger. | +| 82 | **A shared `static` read from `APIClient` (or any nonisolated context) must be declared `nonisolated`** | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` makes **every** type implicitly `@MainActor`, including a bare constants-holder `enum`. `APIClient` is an `actor`, so reading such a static from it warns *"Main actor-isolated static property 'X' can not be referenced from a nonisolated context"* — and that becomes a **hard error** under the Swift 6 language mode, so it will block a toolchain move. `PhotoCaptureFormat.iso8601` hit this from the two `captured_at` multipart call sites. Mark the enclosing enum `nonisolated`, matching `Constants`, `ServerConfig`, `PhotoCaptureFormat` and `SyncManager.isoFormatter` (rule 35). Do **not** reach for `nonisolated(unsafe)` (used nowhere here — it hides the problem) or allocate a formatter per call (rule 35 exists because that cost is real on the upload/sync paths). Foundation formatters are thread-safe for formatting, so one shared instance is correct. | --- diff --git a/JanitorialQC/Utils/PhotoCapture.swift b/JanitorialQC/Utils/PhotoCapture.swift index 9839166..efb01c7 100644 --- a/JanitorialQC/Utils/PhotoCapture.swift +++ b/JanitorialQC/Utils/PhotoCapture.swift @@ -107,11 +107,22 @@ final class PhotoLocationProvider: NSObject, CLLocationManagerDelegate { // MARK: - Wire format -enum PhotoCaptureFormat { +// Explicitly not @MainActor. SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor makes +// every type MainActor-isolated by default, but this formatter is read from +// `actor APIClient` while building the photo-upload multipart body — a +// nonisolated context. Without this the reference warns ("Main actor-isolated +// static property 'iso8601' can not be referenced from a nonisolated context") +// and becomes a hard error under the Swift 6 language mode. Same treatment as +// `Constants` / `ServerConfig` and `SyncManager.isoFormatter` (rule 35). +nonisolated enum PhotoCaptureFormat { /// ISO-8601 with an explicit offset — the format the server's /// `_parse_client_datetime()` expects. A naive string with no offset would /// be read as Eastern wall time, so the offset must always be present. + /// + /// Created once and reused: formatter init is expensive, and this runs per + /// photo upload. Foundation formatters are thread-safe for formatting, so + /// sharing one across isolation domains is safe. static let iso8601: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime]