Files
JQC_iOS_App/JanitorialQC/CLAUDE.md
T

703 lines
43 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-only, all four orientations) |
| 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, PendingPhoto.self, SyncQueueEntry.self
```
**SwiftData lightweight migration:** New `Bool` fields require `= false` default — without it the app crashes on launch.
### 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`, `submitLatitude` (Double?), `submitLongitude` (Double?) |
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON` |
| `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. **`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 |
### `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
```
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only. All Phase A/E optional fields are persisted to `LocalIssue` on both insert and update paths.
---
## 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.**
**`+` 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.
### 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`.
### 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. |
| 61 | **`PhotoThumbnailView` owns `@State private var showLightbox`** | `ReadOnlyCellView.valueView` is a computed `@ViewBuilder` — it cannot hold `@State`. The `image` case delegates to `PhotoThumbnailView` (a separate struct) which holds its own sheet state. Thumbnail is 32×32pt; lightbox is a full-screen black sheet dismissed by tap. |
---
## 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.