# CLAUDE.md — JQC iOS App Developer Reference > **Audience:** AI assistants and developers working on the JanitorialQC iPad app. > **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions. > **Last reviewed:** June 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix + Phase 25 GPS capture at submit time + active-template filter + area relationship fix + read-only inspection detail view) > **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain. --- ## Table of Contents 1. [Project Overview](#1-project-overview) 2. [Tech Stack](#2-tech-stack) 3. [Repository Layout](#3-repository-layout) 4. [App Entry Point & Lifecycle](#4-app-entry-point--lifecycle) 5. [Authentication](#5-authentication) 6. [SwiftData Models](#6-swiftdata-models) 7. [Offline-First Architecture](#7-offline-first-architecture) 8. [Sync Engine (SyncManager)](#8-sync-engine-syncmanager) 9. [API Client (APIClient)](#9-api-client-apiclient) 10. [Navigation & View Hierarchy](#10-navigation--view-hierarchy) 11. [Inspection Workflow](#11-inspection-workflow) 12. [Form Field Rendering](#12-form-field-rendering) 13. [Issue Flagging Workflow](#13-issue-flagging-workflow) 14. [Standalone Issue Creation](#14-standalone-issue-creation) 15. [Re-inspection Workflow](#15-re-inspection-workflow) 16. [Inspection History](#16-inspection-history) 17. [Photo Handling](#17-photo-handling) 18. [Score Calculation](#18-score-calculation) 19. [Background Sync](#19-background-sync) 20. [Settings & Cache Management](#20-settings--cache-management) 21. [Server Selection](#21-server-selection) 22. [Known Constraints & Hard Rules](#22-known-constraints--hard-rules) 23. [Xcode 26 Specific Issues](#23-xcode-26-specific-issues) 24. [Change Philosophy](#24-change-philosophy) --- ## 1. Project Overview **JanitorialQC Inspector** is a native iPadOS app used by inspectors to conduct facility quality-control inspections. Its defining characteristic is **offline-first operation**: every action writes to local SwiftData storage first; the server is a secondary destination reached asynchronously when connectivity allows. Core capabilities: - **Login/logout** via JWT against the JQC web backend API - **Server selection** — inspector can choose between jqc (primary) and jqc1 (secondary) server at login or in Settings - **Reference data sync** — facilities, areas, and inspection templates pulled from server - **Inspection execution** — dynamic form rendering driven by server-side template schemas - **Issue flagging** — severity-tagged issues with optional photos, attached to inspections - **Standalone issue creation** — inspector can create issues directly from the Issues page without an active inspection, with contract → facility cascade picker - **Re-inspection** — linked follow-up inspections with parent-form pre-fill - **Outbox queue** — completed inspections and issues submitted to server automatically when online - **Inspection history** — server-side read-only history for completed inspections - **Issues list** — assigned/reported issues pulled from server + device-created issues - **Issue detail** — shows local photos (pending) or server photos (synced) via `RetryablePhotoView` - **Facilities browser** — read-only view of synced facilities and their areas - **Push notifications** — local notifications via 60-second polling --- ## 2. Tech Stack | Layer | Technology | |---|---| | Language | Swift 5.10+ | | 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) | | Token storage | iOS Keychain (Security.framework) | | Photo capture | UIImagePickerController (camera), PHPickerViewController (library) | | Signature capture | PencilKit (PKCanvasView) | | Background tasks | BGTaskScheduler / BGProcessingTask | | Server config | UserDefaults (not Keychain — not a secret) | | Min deployment | iOS 17.0 | | Xcode | **26.4.1** (Xcode 26 beta — see §23 for critical constraints) | --- ## 3. Repository Layout ``` JanitorialQC/ ├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration ├── ContentView.swift # Auth gate + startup lifecycle (.task{}) │ ├── Auth/ │ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore │ └── KeychainHelper.swift # Security.framework wrapper (nonisolated) │ ├── API/ │ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload │ │ # updateIssuePhotos() — PATCH /issues//photos │ └── APIModels.swift # All Codable/Sendable response DTOs │ # APIAssignedIssue has photoPath + mobilePhotoPaths + resultPhotos │ ├── Sync/ │ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue │ # pullReferenceData() deduplicates facilities by serverId │ # pullAssignedIssues() reads photo_path + mobile_photo_paths only │ ├── Models/ # SwiftData @Model classes — NO other .swift files here │ ├── LocalFacility.swift # @Attribute(.unique) serverId; projectId/projectName for contracts │ ├── LocalArea.swift │ ├── LocalTemplate.swift │ ├── LocalInspection.swift │ ├── LocalIssue.swift # photoLocalPathsJSON + photoServerPathsJSON (both JSON-encoded) │ ├── PendingPhoto.swift │ └── SyncQueueEntry.swift │ ├── Views/ │ ├── Auth/ │ │ └── LoginView.swift # Server picker (segmented) above login form │ ├── Dashboard/ │ │ ├── DashboardView.swift # Sidebar + all detail views │ │ │ # MyInspectionsView owns + button (not sidebar) │ │ │ # IssuesListView owns + button → StandaloneIssueView │ │ │ # RetryablePhotoView — AsyncImage with tap-to-retry │ │ │ # IssueDetailView — pending shows local, synced shows server │ │ ├── StartInspectionView.swift # Contract → Facility → Area cascade pickers │ │ ├── ExecuteInspectionView.swift │ │ ├── FlagIssueView.swift │ │ └── FormFieldView.swift │ └── Inspection/ │ └── InspectionHistoryView.swift # Uses RetryablePhotoView for server photos │ └── Utils/ └── Constants.swift # ServerConfig (UserDefaults), ServerOption enum, Keychain keys # Constants.baseURL is GONE — use ServerConfig.current everywhere ``` **Critical:** This project uses `PBXFileSystemSynchronizedRootGroup` (Xcode 26 folder sync). Xcode **automatically compiles every `.swift` file in the folder tree**. There is no explicit file list. A stray duplicate causes "Multiple commands produce" build errors. --- ## 4. App Entry Point & Lifecycle ### JanitorialQCApp.swift `@main` struct. Sets **only** `SyncManager.shared.modelContext` in the container callback. No async calls from there. ### ContentView.swift Startup sequence in `.task {}`: ```swift .task { await AuthManager.shared.restoreSession() // 1. auth first SyncManager.shared.startMonitoring() // 2. monitor after auth resolves if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated { await SyncManager.shared.triggerSync() // 3. sync only if authenticated } } ``` **The order is mandatory.** `startMonitoring()` must not be called before `restoreSession()` completes — NWPathMonitor fires immediately, triggering `triggerSync()` before tokens exist. --- ## 5. Authentication ### AuthManager `@MainActor class AuthManager: ObservableObject` — singleton via `AuthManager.shared`. **Session restore:** Validates stored token via `GET /api/v1/auth/me`. On `notAuthenticated` → clears Keychain. On other errors (network) → restores from Keychain and sets authenticated (offline launch). **Logout:** Calls `POST /api/v1/auth/logout` best-effort, clears Keychain, sets `isAuthenticated = false`. ### KeychainHelper `nonisolated` static methods. All keys use `kSecAttrAccessibleAfterFirstUnlock`. Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`, `userRole`, `username`, `displayName`. --- ## 6. SwiftData Models ### Model Container Registration ```swift LocalFacility.self, LocalArea.self, LocalTemplate.self, LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self, PendingPhoto.self, SyncQueueEntry.self ``` **SwiftData lightweight migration:** New `Bool` fields require `= false` default — without it the app crashes on launch. New non-optional model properties need inline defaults too; new **optional** properties (e.g. the `LocalIssue` handler fields) are nil-safe. **Exception:** a `@Attribute(.unique)` key must have NO default (rule 63). ### Model Reference | Model | Role | Key fields | |---|---|---| | `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`, `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), `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` | ### LocalInspection Status Flow ``` "draft" → "completed" → "synced" → "failed" (after 5 retries) ``` `status` = inspector workflow state. `syncStatus` = server submission state (`"pending"` | `"synced"` | `"failed"`). --- ## 7. Offline-First Architecture ``` Inspector action → SwiftData write (always succeeds immediately) ↓ syncStatus = "pending" ↓ NWPathMonitor detects connectivity ↓ SyncManager.triggerSync() 1. Upload pending photos (processPhotoQueue) 2. Submit completed inspections (processInspectionQueue) 3. Submit pending issues (processIssueQueue) 4. Pull fresh reference data (pullReferenceData) 5. Pull + reconcile assigned issues (pullAssignedIssues) 6. Poll server notifications (pollNotifications) ``` --- ## 8. Sync Engine (SyncManager) `@MainActor class SyncManager: ObservableObject` — singleton via `SyncManager.shared`. ### triggerSync() guard ```swift guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } ``` ### triggerSync() — processing order 1. **`processPhotoQueue`** — uploads all `PendingPhoto` with `uploadStatus == "pending"`. On success: propagates `serverPath` to `LocalInspection.formData[fieldId]` (inspection image fields) or appends to `LocalIssue.photoServerPaths` (issue photos). Fetch-all + filter in Swift — no `#Predicate`. 2. **`processInspectionQueue`** — submits completed inspections when all `pendingPhotos` are settled. 3. **`processIssueQueue`** — guards against submitting when parent inspection `syncStatus == "failed"`. After successful submit: sets `syncStatus = "synced"`, **clears `photoLocalPaths = []`** (prevents duplicate photo sections in `IssueDetailView`), then calls `updateIssuePhotos(issueId:resultPhotos:)` for any extra photos beyond the first (`Array(photoServerPaths.dropFirst())`). 4. **`pullReferenceData`** — fetches facilities, areas, templates. **Deduplicates facility response by `id` using `seenFacilityIds = Set()`** before upserting — prevents duplicate buildings in pickers when server returns same facility ID multiple times. **Prunes facilities the server no longer returns (Aug 2026).** `/api/v1/facilities` is already scoped server-side, but cached rows were never removed, so a facility survived locally after the inspector's contract was unassigned, after it was deactivated, or after a **different user signed in on the same iPad**. Every picker derives its **contract** list from these rows (`StartInspectionView.contracts`, `IssuesView.contracts` both map over `LocalFacility`), so one stale facility kept a whole contract in the Start Inspection picker forever — which is how this surfaced. Templates already had this prune; facilities were the gap. Two rules in the prune: - Deletion only runs after **both** requests succeeded, so a failed sync can never empty the cache (it throws first). - A facility still referenced by **unsynced** local work (a `LocalInspection` or `LocalIssue` with `syncStatus != "synced"`) is **kept but marked `isActive = false`** instead of deleted. `ExecuteInspectionView`/`MyInspectionsView` resolve the facility name by `serverId` and the issue *detail* view has no `facilityNameCache` fallback, so deleting it would turn an in-progress draft into "Unknown Facility". The row is pruned on a later sync once that work has been submitted, and `update(from:)` flips `isActive` back to true if the facility returns to scope. **Every picker must therefore filter on `isActive`** — both views expose `availableFacilities` for this and derive `contracts` / `filteredFacilities` from it, never from the raw `@Query`. A retained out-of-scope row is for *display only*; offering it would let an inspector start work the server then rejects. 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. **`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 Records inserted by `pullAssignedIssues` are identified by: `syncStatus == "synced"` AND `inspectionLocalId == ""`. These are the only records safe to delete during reconciliation. ### clearServerPulledData() — on logout / server switch Deletes every `LocalIssue` where `serverId != nil`. This covers: - Server-pulled assigned issues (`inspectionLocalId == ""`, `syncStatus == "synced"`) - Inspector-created issues that already synced (`inspectionLocalId != ""`, `serverId != nil`) Preserves only truly pending device-created issues (`serverId == nil`, `syncStatus == "pending"`). ### Notification polling - `startPollTask()` creates a `Task` with `Task.sleep(nanoseconds: 60_000_000_000)` loop. - **`Timer.scheduledTimer` is banned** — inside `Task { @MainActor }`, `RunLoop.current ≠ RunLoop.main`; timer fires never. - `resetNotificationPoller()` cancels task + clears cursor. Call on logout. ### Fetch pattern — CRITICAL for Xcode 26 **Never use `#Predicate` in `SyncManager`.** Use fetch-all + filter in Swift. Always parenthesise `try?` before `??`: ```swift // CORRECT let all = (try? context.fetch(FetchDescriptor())) ?? [] let x = all.filter { ... } ``` --- ## 9. API Client (APIClient) `actor APIClient` — singleton via `APIClient.shared`. All methods are `async throws`. All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.baseURL` no longer exists.** ### JSON decoding `decoder.keyDecodingStrategy = .convertFromSnakeCase` — snake_case server fields map to camelCase automatically. Server POST body keys are snake_case (`photo_path`, `result_photos`, `facility_id`, etc.). ### Key methods | Method | Endpoint | Notes | |---|---|---| | `request` | Any | Generic; 401 auto-refresh once | | `post` | Any | POST convenience | | `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data; `entity_type="issue"` → `uploads/issue_photos/` | | `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` paths | | `submitIssue` | `POST /api/v1/issues` | Sends `photo_path` = first server photo only | | `updateIssuePhotos` | `PATCH /api/v1/issues//photos` | Sends `{ "result_photos": [extra paths] }`; stored server-side in `mobile_photo_paths` | | `fetchAssignedIssues` | `GET /api/v1/issues` | Returns issues assigned to OR reported by current user | | `fetchIssueDetail` | `GET /api/v1/issues/` | Fetches current status | | `updateIssueStatus` | `PATCH /api/v1/issues//status` | Inspector updates status | | `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 ```swift let photoPath: String? // primary evidence photo let mobilePhotoPaths: [String] // extra evidence photos from iPad (mobile_photo_paths) let resultPhotos: [String] // resolution photos — NOT stored in photoServerPaths on iPad // Phase A: let resultNotes: String? // resolution notes from web staff let verifiedAt: String? // ISO 8601 — when fix was verified let verificationNote: String? // verifier note let reportedByName: String? // reporter display_name // Phase E: let areaName: String? // area the issue was flagged in let assignedToName: String? // assigned user display_name // Handler ("Handled By", July 2026): let handlerType: String? // "internal" | "facility" | "vendor" let handlerLabel: String? // human-readable label let facilityHandlerName: String?, facilityHandlerContact: String?, facilityHandlerNotes: String? let vendorName: String?, vendorContact: String?, vendorNotes: String? ``` `SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only. All Phase A/E and handler optional fields are persisted to `LocalIssue` on both insert and update paths; `refreshStatusFromServer()` also refreshes the handler fields from `APIIssueDetail` (skipped while the inspector is mid-edit). The same handler fields exist on `APIIssueDetail`. --- ## 10. Navigation & View Hierarchy ``` JanitorialQCApp └── ContentView (auth gate + startup lifecycle) ├── LoginView (unauthenticated) │ └── Server picker (segmented: jqc Primary / jqc1 Secondary) └── DashboardView (authenticated) ├── Sidebar (NavigationSplitView — no selection: binding) │ ├── Dashboard → DashboardStatsView [default landing — KPI tiles] │ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar] │ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded] │ ├── Facilities → FacilitiesListView │ ├── Pending Sync → SyncStatusView │ ├── History → InspectionHistoryView │ ├── Notifications → NotificationsView [red badge when unreadCount > 0] │ └── Settings → SettingsView [server picker + logout alert] └── (sidebar has NO + button) ``` **`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`. --- ## 11. Inspection Workflow ### 1. Start (`StartInspectionView`) Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange(of: selectedProjectId)` guards against resetting pre-filled facility — checks `facilityBelongsToContract` before clearing `selectedFacilityId`. ### 2. Execute (`ExecuteInspectionView`) - Renders 12-column grid form. Auto-saves every 30 seconds. - Submit: computes score, sets `status = "completed"`, `syncStatus = "pending"`, calls `clearParentFollowUpFlag()`, triggers sync. - Shows success banner 2.5 seconds then dismisses. **GPS capture (Phase 25):** `InspectionLocationManager` (thin `CLLocationManager` wrapper defined at the bottom of `ExecuteInspectionView.swift`) begins acquiring a fix the moment the submit confirm dialog appears. On successful fix, `inspection.submitLatitude` and `inspection.submitLongitude` are set before the inspection is marked completed. GPS is best-effort — nil on permission denial or location failure; submission still proceeds normally. `APIClient.submitInspection` sends `submit_latitude` / `submit_longitude` only when non-nil. The `PATCH` endpoint does not accept GPS fields — creation-time (POST) capture only. The server displays a Google Maps embed in `inspections/view.html` for admin/director when both fields are present. ### 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: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. **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. **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. ### 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. ### "Handled By" (issue handler, phase35 → mobile July 2026) `IssueDetailView` (`IssuesView.swift`) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM **and the assigned inspector** — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via `updateIssueHandler` and mirrors the result onto `LocalIssue`. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78). ### 3. Draft management Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted. --- ## 12. Form Field Rendering ### GridFormView — 12-column absolute-position grid | Constant | Value | |---|---| | `totalColumns` | 12 | | `cellGap` | 8 pt | | `rowGap` | 4 pt | | `cellAspect` | 52/72 | | `cardPadding` | 16 pt | ### Field ID rule (CRITICAL) Form schema IDs arrive as `Int` in `[String: Any]`. Always cast: ```swift let fid: String if let s = field["id"] as? String { fid = s } else if let n = field["id"] as? Int { fid = String(n) } else { continue } ``` **Never use `Optional.map` on `field["id"]`** — produces `"Optional(5)"` instead of `"5"`. --- ## 13. Issue Flagging Workflow `FlagIssueView` — presented as sheet from `ExecuteInspectionView`. Sets `inspectionLocalId = inspection.localId`, `areaServerId = inspection.areaServerId`, and appends to `inspection.localIssues`. Creates `PendingPhoto` with `entityType = "issue"`. `areaServerId` is sent as `area_id` in `submitIssue()`. **Issue sync guard:** If parent `inspection.syncStatus == "failed"`, issue is immediately marked `"failed"` — prevents orphaned server records. --- ## 14. Standalone Issue Creation `StandaloneIssueView` — presented as sheet from `IssuesListView` via `+` button. - **Contract picker** → `Picker` with `.navigationLink` style. `onChange` resets `selectedFacilityId` unless the current facility already belongs to the new contract. - **Facility picker** — gated: only appears after a contract is selected. Shows only facilities for the selected `projectId`. - Severity segmented picker + description + up to 5 photos (camera + library). - On submit: creates `LocalIssue` with `inspectionLocalId = ""` (same as server-pulled issues). `processIssueQueue` picks it up and submits — the parent guard (`parent?.syncStatus == "failed"`) evaluates to `false` for `""` `inspectionLocalId` so it proceeds normally. **Contract/facility data source:** `@Query(sort: \LocalFacility.name)` — same cached reference data as `StartInspectionView`. `contracts` computed var deduplicates by `projectId` using `var seen = Set()`. `filteredFacilities` deduplicates by `serverId` using the same pattern. --- ## 15. Re-inspection Workflow ### Trigger `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()`. 2. After sync in `SyncManager.processInspectionQueue`. 3. On the server when PATCH/POST arrives. --- ## 16. Inspection History `InspectionHistoryView` — online-only. Fetches `GET /api/v1/inspections?status=completed`. Pagination: limit 30, offset-based. Uses `RetryablePhotoView` for server photos. --- ## 17. Photo Handling ### Capture `UIImagePickerController` (camera) or `PHPickerViewController` (library, no permission required iOS 16+). ### Local storage `Documents/JQC/Photos/.jpg` at JPEG quality 0.8. ### PendingPhoto lifecycle ``` Created (uploadStatus="pending") ↓ SyncManager.processPhotoQueue() Uploaded (uploadStatus="uploaded", serverPath set) ↓ Parent record updated Inspection image fields: LocalInspection.formData[fieldId] = serverPath Issues: LocalIssue.photoServerPaths.append(serverPath) ``` ### Multi-photo issue submission sequence ``` 1. processPhotoQueue: uploads all N photos → appends each serverPath to issue.photoServerPaths 2. processIssueQueue: submitIssue(issue) → sends photo_path = photoServerPaths[0] issue.photoLocalPaths = [] (clear local paths — prevents duplicate sections) updateIssuePhotos(issueId, photoServerPaths.dropFirst()) → PATCH /issues//photos with extras ``` ### Photo display in IssueDetailView Gated on `syncStatus`: - **`syncStatus != "synced"` (pending/failed):** Shows `photoLocalPaths` section only — local files from disk via `UIImage(contentsOfFile:)`. `photoServerPaths` may be partially populated from mid-sync uploads; hiding it prevents a mix of working and broken images. - **`syncStatus == "synced"`:** Shows `photoServerPaths` section only via `RetryablePhotoView`. `photoLocalPaths` has been cleared by `processIssueQueue`. ### Server photo URL construction All server photo URLs: `ServerConfig.current + "/static/" + relativePath` The server stores photos under `app/static/uploads/` and serves them via Flask's static handler at `/static/`. The `/static/` prefix is **required** — omitting it returns 404. ### RetryablePhotoView `struct RetryablePhotoView: View` defined in `DashboardView.swift`, used by both `IssueDetailView` and `InspectionHistoryView`. - Holds `@State private var reloadToken = UUID()`. - `AsyncImage` carries `.id(reloadToken)` — toggling the UUID forces SwiftUI to destroy and recreate the `AsyncImage`, triggering a fresh network request. - On `.failure`: shows "Photo unavailable" label + **Retry** button that sets `reloadToken = UUID()`. - `AsyncImage` has no built-in retry. Once it enters `.failure` (transient network error, SSL hiccup) it stays there for the view's lifetime. `RetryablePhotoView` is the fix. - Uses `transaction: Transaction(animation: .easeIn)` for fade-in on success. ### SF Symbol availability Only use symbols available on iOS 17+. Known unavailable symbols: - `photo.slash` — **BANNED**, does not exist on all iOS 17 devices. Use `exclamationmark.triangle`. - `photo.badge.exclamationmark` — introduced iOS 16 but not universally available. Use `exclamationmark.triangle`. ### local:// sentinel While a photo is pending upload, the inspection form field value is `"local://"`. Before `submitInspection` sends `formData`, these are replaced with `""`. A `local://` value reaching the server is a malformed path. --- ## 18. Score Calculation `LocalInspection.computeScore(fromSchema:)` mirrors Python's `_compute_score_from_form()`. **Scoreable:** `rating`, `checkbox`, `radio`, `pass_fail`. | Type | Rule | |---|---| | `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0 | | `checkbox` | `"true"` = pass | | `radio` | Pass: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) | | `pass_fail` | Same keywords. Empty = unanswered → excluded | Returns `nil` if no scoreable fields or all unanswered. --- ## 19. Background Sync Registers `BGProcessingTask` with identifier `com.jqc.sync`. `Info.plist` requirement: `BGTaskSchedulerPermittedIdentifiers` must contain `com.jqc.sync`. Project uses `GENERATE_INFOPLIST_FILE = YES` — **no physical `Info.plist` in source tree**. Requirements: `requiresNetworkConnectivity = true`, `requiresExternalPower = false`. --- ## 20. Settings & Cache Management `SettingsView` exposes: - **Account info** — username, role (read-only). - **Sync Now** — triggers `triggerSync()`; disabled when offline or syncing. - **Clear Reference Cache** — deletes `LocalFacility`, `LocalArea`, `LocalTemplate` only. Never touches `LocalInspection`, `LocalIssue`, `PendingPhoto`. Triggers `pullReferenceData()` if online. - **Server picker** — see §21. - **Log Out** — calls `clearServerPulledData()` + `resetNotificationPoller()` + `auth.logout()`. - App version + current server URL (from `ServerConfig.current`). --- ## 21. Server Selection ### Architecture Server is selected at runtime from two options. Selection persists to `UserDefaults` (not Keychain — not a secret). ```swift enum ServerOption: String, CaseIterable, Sendable { case primary = "https://jqc.ltservicesinc.com" // default case secondary = "https://jqc1.ltservicesinc.com" } enum ServerConfig { static var current: String { /* reads UserDefaults */ } @MainActor static func select(_ option: ServerOption) @MainActor static var selectedOption: ServerOption } ``` **`Constants.baseURL` no longer exists.** All code that previously read `Constants.baseURL` now reads `ServerConfig.current`. ### Login page Segmented picker above the credential fields. `onChange` calls `ServerConfig.select(newValue)` immediately. The very next `Sign In` tap hits the selected server — no restart required when changing at login. ### Settings page Segmented picker in a "Server" section. `onChange` snaps the picker back to the current saved server, stores intent in `pendingServer`, and shows `Alert("Switch Server?")`. **Alert actions:** - **Switch & Log Out (destructive):** `ServerConfig.select(chosen)` → `clearServerPulledData()` → `resetNotificationPoller()` → `auth.logout()`. - **Cancel:** clears `pendingServer`, picker stays on original. **Why logout is required on server switch:** `serverId` values are server-specific. A `LocalIssue` with `serverId = 48` from `jqc.ltservicesinc.com` has no meaning on `jqc1.ltservicesinc.com`. Keeping stale records causes "Issue not found" errors on every status fetch/update. ### clearServerPulledData() boundary Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` records (pending, never synced). This is the correct boundary — not `syncStatus == "synced" && inspectionLocalId == ""` (the old incorrect filter that missed inspector-created synced issues). --- ## 22. Known Constraints & Hard Rules | # | Rule | Rationale | |---|---|---| | 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine | | 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 | | 3 | **No `#Predicate` anywhere in `SyncManager`** | Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` causes `LocalInspection is ambiguous` cascade | | 4 | **Sequential `await` in `pullReferenceData()`** | `async let` causes Swift 6 actor-isolation warnings on `Decodable` | | 5 | **PencilKit requires explicit framework linkage** | Add `PencilKit.framework` under Target → Frameworks | | 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected | | 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens readable after reboot for BGTaskScheduler | | 8 | **New `Bool` model fields require `= false` default** | SwiftData lightweight migration crashes without default | | 9 | **Field IDs in formData are always String keys** | Server encodes as Int; cast via `as? String` then `as? Int → String(n)`. Never `Optional.map` | | 10 | **Strip `local://` paths from formData before `submitInspection`** | JSONSerialization silently drops non-serialisable values | | 11 | **Do not submit an issue when parent inspection `syncStatus == "failed"`** | Creates orphaned server records | | 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` runs before `processInspectionQueue` | | 13 | **`com.jqc.sync` must be in BGTaskSchedulerPermittedIdentifiers** | Silently ignored if absent | | 14 | **`refreshAccessToken()` uses a local JSONDecoder, not `self.decoder`** | Actor-isolation error in Swift 6 | | 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility ambiguous with multiple follow-ups | | 16 | **`SyncQueueEntry` model registered but not actively written** | Future use; `syncStatus` on models is the active queue | | 17 | **All server URLs read from `ServerConfig.current`** | `Constants.baseURL` no longer exists. Every endpoint: `ServerConfig.current + path`. Never hardcode a server URL. | | 18 | **Photo JPEG compression is 0.8** | Do not raise above 0.85 without testing 50 MB server limit | | 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge | | 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | Allows offline launch | | 21 | **`startMonitoring()` must be called AFTER `restoreSession()` completes** | NWPathMonitor fires immediately; 401 loop leaves `isLoading` stuck | | 22 | **`triggerSync()` guards on `AuthManager.shared.isAuthenticated`** | Prevents sync before auth established | | 23 | **Do NOT place model files in non-Model folders** | Xcode 26 folder sync compiles every `.swift`; duplicate causes build error | | 24 | **No physical `Info.plist` when `GENERATE_INFOPLIST_FILE = YES`** | "Multiple commands produce Info" build error | | 25 | **Always parenthesise `try?` before `??`** | `try? fetch(...) ?? []` parses as `try? (fetch() ?? [])` — nonsensical | | 26 | **Delete Xcode's default `Item.swift` immediately after project creation** | Compiles silently via folder sync, conflicts with real models | | 27 | **`Timer.scheduledTimer` must NOT be used for periodic work in SyncManager** | `RunLoop.current ≠ RunLoop.main` inside `Task { @MainActor }`; timer fires never | | 28 | **`UNUserNotificationCenterDelegate` must be set for foreground notifications** | Without it, iOS drops local notifications while app is active | | 29 | **All `Decodable & Sendable` structs need `nonisolated init(from:)`** | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` taints synthesised Decodable inits | | 30 | **`pullAssignedIssues` deletion pass must NOT be short-circuited on empty response** | If server returns zero issues, deletion pass must still run | | 31 | **Server-pulled issues: `syncStatus == "synced"` + `inspectionLocalId == ""`** | Only these are safe to delete during reconciliation. Standalone device-created issues have `inspectionLocalId == ""` too but `serverId == nil` until synced — they survive. | | 32 | **`StartInspectionView.onChange(of: selectedProjectId)` guards pre-filled facility** | Check `facilityBelongsToContract` before clearing `selectedFacilityId` | | 33 | **`photoServerPaths` = `photo_path` + `mobile_photo_paths` only** | `result_photos` (resolution photos) is intentionally excluded from `photoServerPaths` on iPad | | 34 | **`IssueDetailView` photo display is gated on `syncStatus`** | Pending: show `photoLocalPaths` only. Synced: show `photoServerPaths` only. Never both simultaneously — mixed state causes "Photo unavailable" for mid-upload server paths | | 35 | **`SyncManager.isoFormatter` is the only date formatter — never allocate per-call** | `DateFormatter` init is expensive; `nonisolated static let` with `en_US_POSIX` locale | | 36 | **`uploadPhoto(retrying:)` — pass `retrying: true` on recursive retry** | Prevents double token refresh on 401 during retry | | 37 | **`LocalIssue.inspection` must declare explicit `@Relationship` inverse** | `@Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues)` prevents implicit inverse ambiguity | | 38 | **`APIUser.displayName` uses `fullName` when non-empty** | `fullName.isEmpty ? username : fullName` — mirrors `User.display_name` server-side | | 39 | **Server photo URLs include `/static/` prefix** | Server stores at `app/static/uploads/`; Flask serves at `/static/uploads/`. URL = `ServerConfig.current + "/static/" + relativePath`. Missing `/static/` returns 404. | | 40 | **Use `RetryablePhotoView` for all server photo loads** | `AsyncImage` has no retry — once in `.failure` it stays there for the view's lifetime. `RetryablePhotoView` allows tap-to-retry by toggling `.id(reloadToken)`. | | 41 | **Only use SF Symbols available on iOS 17** | `photo.slash` and `photo.badge.exclamationmark` are absent on some devices. Use `exclamationmark.triangle` for all photo-error states. | | 42 | **`clearServerPulledData()` boundary is `serverId != nil`** | Old boundary `syncStatus == "synced" && inspectionLocalId == ""` missed inspector-created synced issues, leaving stale serverIds that caused "Issue not found" after server switch. | | 43 | **`processIssueQueue` clears `photoLocalPaths` after successful submit** | Prevents `IssueDetailView` from rendering a duplicate "local photos" section alongside the server photos section for synced issues. | | 44 | **`StandaloneIssueView` uses `inspectionLocalId = ""`** | Same pattern as server-pulled issues. `processIssueQueue`'s parent-inspection guard evaluates `parent?.syncStatus == "failed"` → `false` for `""`, so standalone issues submit normally. | | 45 | **Facility lists deduplicate by `serverId` at both storage and display layers** | Storage: `pullReferenceData()` deduplicates server response before upsert. Display: `filteredFacilities` in both `StartInspectionView` and `StandaloneIssueView` uses `filter { seen.insert($0.serverId).inserted }`. | | 46 | **`LocalIssue` Phase A–E fields all use nil-default optionals** | `String?`, `Date?` optionals default to nil. SwiftData lightweight migration supports nil-default optional properties without a migration plan. | | 47 | **`IssueDetailView` comments block must be inside `List { }`** | `.navigationTitle` and `.task` must chain on the `List` view. Placing the `if sync.isOnline { }` comments block outside `List` causes `Instance member ‘navigationTitle’ cannot be used on type ‘View’` build error. | | 48 | **`IssuesListView` filters resolved in Swift, not via `#Predicate`** | `@Query` returns `allIssues`; computed `var issues` filters `$0.issueStatus != "resolved"`. `#Predicate` with string literals on `LocalIssue` is unreliable under Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION` (rule 3). | | 49 | **`SyncManager.fetchDashboardStats()` is best-effort — never blocks pipeline** | Called last in `triggerSync()`. Failure leaves `dashboardStats` nil; UI shows placeholder. Never propagates errors. | | 50 | **`NotificationsView.markNotificationsViewed()` on both `.onAppear` and sidebar tap** | Sidebar tap calls `sync.markNotificationsViewed()` inline to clear the badge immediately without waiting for navigation. | | 51 | **`FlagIssueView.submitIssue()` must copy `inspection.areaServerId` to `issue.areaServerId`** | Without this, the server cannot link the issue to the correct area. `APIClient.submitIssue` sends `area_id` only when `issue.areaServerId` is non-nil. | | 52 | **`LocalIssue.swift` zip delivery must include all Phase A–E fields** | When packaging, copy from the working-tree file and verify every field with `grep` before zipping. Partial field sets cause `SyncManager` build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phase’s file was not in working tree. Fix: always `cp` back immediately after creating a phase file. | | 53 | **`submitLatitude`/`submitLongitude` on `LocalInspection` are `Double?` optionals** | SwiftData lightweight migration supports nil-default optionals without a migration plan. `APIClient.submitInspection` sends them only when non-nil via `if let lat = inspection.submitLatitude`. Never make them non-optional — GPS is best-effort and must not block submission on permission denial or hardware failure. | | 54 | **`InspectionLocationManager.startUpdating()` called when confirm dialog appears, not at view load** | Starting too early wastes battery. The confirm dialog provides a natural ~1–2 second window before the user taps Confirm, giving the manager time to acquire a fix. GPS captured into `LocalInspection` immediately before marking `status = "completed"`. | | 55 | **`StartInspectionView` must filter templates in Swift, not via `@Query` predicate** | `@Query(sort: \LocalTemplate.name)` fetches all into `allTemplates`; computed `var templates` filters `{ $0.isActive }`. `#Predicate` with `isActive` is unreliable under Xcode 26 rule 3. | | 56 | **`LocalTemplate.isActive` must have inline default `= true`** | Added in this session. SwiftData lightweight migration requires all new `Bool` fields to carry an inline default (rule 8). `GET /api/v1/templates` now only returns active templates; `pullReferenceData` deletes cached templates not in the server response so inactive ones never appear in pickers even offline. | | 57 | **`upsertAreas` must set `newArea.facility = facility` at insert time** | SwiftData relationship wiring requires the inverse to be explicitly assigned. Without `newArea.facility = facility`, `LocalFacility.areas` is always empty and the area picker shows nothing. The `LocalFacility` object (from `facilityMap` or just inserted) is passed into `upsertAreas` as a parameter. Also re-wires on update: `if ex.facility == nil { ex.facility = facility }`. | | 58 | **`ReadOnlyGridFormView` uses `rowView` (GeometryReader + ZStack), NOT a ZStack canvas or LazyVGrid** | ZStack canvas: gaps from unanswered rows because y-offsets are absolute. LazyVGrid: ignores `col` position, flows items sequentially. Correct approach: group fields by original `row` into `RowGroup`s, render each group as a `GeometryReader` that divides width by 12 to get `colW`, positions each field with `.offset(x: colW * (col-1))` and `.frame(width: colW * colSpan)`. `VStack(spacing: 3)` between rows. Row height fixed at 36pt (section headers 28pt). | | 59 | **Read-only inspection detail: only answered fields are shown — filtering is 5-pass** | Pass 1: collect `answeredIds` (rating > 0, or non-empty value). Pass 2: collect `visibleLabelIds` (labels immediately before an answered field). Pass 3: collect `visibleSectionIds` (sections with at least one answered field after them). Pass 4: group all schema fields by original `row`. Pass 5: for each row group, emit only visible fields; skip rows with no visible content. | | 60 | **`ReadOnlyGridFormView` rows advance by 1 regardless of original `rowSpan`** | The web renders every field with `grid-row: N / span 1`. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space. | | 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`/`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 **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. | | 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. | | 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. | --- ## 23. Xcode 26 Specific Issues This project was created with **Xcode 26.4.1**. See previous entries in §22 (rules 3, 4, 23, 25, 29) for the specific workarounds. ### SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor Every type and function is implicitly `@MainActor`. Effects on SwiftData `FetchDescriptor` and `#Predicate`: see §8 and rule 3. **Solutions applied:** 1. Fetch-all + filter in Swift — no `#Predicate` with captured variables. 2. All `try? context.fetch(...)` parenthesised before `??`. 3. Chained optional patterns split into two `let` statements. 4. All API model structs declare `nonisolated init(from decoder: any Decoder)` explicitly. **Do NOT remove `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` from build settings** unless you audit every file for the resulting isolation changes. ### PBXFileSystemSynchronizedRootGroup (Folder Sync) Every `.swift` file in the project folder is compiled automatically. Stray files cause "Multiple commands produce" errors. Deleting from Finder is sufficient — no project navigator change needed. ### Derived Data corruption After "Multiple commands produce" errors: delete derived data manually: ```bash rm -rf ~/Library/Developer/Xcode/DerivedData/- ``` Do not rely on Product → Clean Build Folder alone. --- ## 24. Change Philosophy 1. **Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it. 2. **Trace the full data path.** View → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer. 3. **Root cause, not symptom.** State the root cause explicitly before proposing a fix. 4. **Smallest possible change.** Do not restructure, rename, or reformat surrounding code. 5. **Never remove functionality** unless explicitly directed. 6. **SwiftData schema changes need defaults.** All new `Bool` fields: `= false`. 7. **Test offline and online.** Every sync-related fix must be verified in airplane mode. 8. **Verify file placement.** Xcode 26 folder sync means a file in the wrong subfolder compiles as a duplicate. 9. **When errors persist unchanged across multiple fix attempts, the file is not being picked up.** Ask the user to paste the current file content. 10. **Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.