Files
JQC_iOS_App/JanitorialQC/CLAUDE.md
T

777 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 7677. 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/<id>/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<Int>()`** before upserting — prevents duplicate buildings in pickers when server returns same facility ID multiple times.
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<LocalIssue>())) ?? []
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<T>` | Any | Generic; 401 auto-refresh once |
| `post<T>` | 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/<id>/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/<id>` | Fetches current status |
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/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/<id>/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 6364 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<Int>()`. `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/<UUID>.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/<id>/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://<path>"`. 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 AE 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 AE 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 phases 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 ~12 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 '<OtherModel>' 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 <built .app>/Info.plist \| grep -A2 BGTask` must show an array. |
| 74 | **Two different background mechanisms — do not confuse them** | `BGProcessingTask` (JanitorialQCApp) asks iOS to **wake us later**: opportunistic, typically charging + Wi-Fi + idle, not a heartbeat. `beginBackgroundTask` (`SyncManager.beginSyncBackgroundTask`) asks iOS **not to suspend us right now**: ~30 s, covers submit-then-lock. The expiration handler must end the assertion or iOS terminates the app, and it needs `MainActor.assumeIsolated` because the closure is nonisolated under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. |
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
| 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. |
---
## 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/<ProjectName>-<hash>
```
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.