Files
JQC_iOS_App/JanitorialQC/CLAUDE.md
T

70 KiB
Raw Blame History

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
  2. Tech Stack
  3. Repository Layout
  4. App Entry Point & Lifecycle
  5. Authentication
  6. SwiftData Models
  7. Offline-First Architecture
  8. Sync Engine (SyncManager)
  9. API Client (APIClient)
  10. Navigation & View Hierarchy
  11. Inspection Workflow
  12. Form Field Rendering
  13. Issue Flagging Workflow
  14. Standalone Issue Creation
  15. Re-inspection Workflow
  16. Inspection History
  17. Photo Handling
  18. Score Calculation
  19. Background Sync
  20. Settings & Cache Management
  21. Server Selection
  22. Known Constraints & Hard Rules
  23. Xcode 26 Specific Issues
  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 {}:

.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

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

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 ??:

// 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 + endpointConstants.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

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 NavigationLinks. 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) → .fullScreenCoverExecuteInspectionView(isModallyPresented: true) with a leading Close; scheduled / new (+) / re-inspection → .fullScreenCoverStartInspectionView (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:

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 pickerPicker 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.slashBANNED, 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 = YESno 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).

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 RowGroups, render each group as a GeometryReader that divides width by 12 to get colW, positions each field with .offset(x: colW * (col-1)) and .frame(width: colW * colSpan). VStack(spacing: 3) between rows. Row height fixed at 36pt (section headers 28pt).
59 Read-only inspection detail: only answered fields are shown — filtering is 5-pass Pass 1: collect answeredIds (rating > 0, or non-empty value). Pass 2: collect visibleLabelIds (labels immediately before an answered field). Pass 3: collect visibleSectionIds (sections with at least one answered field after them). Pass 4: group all schema fields by original row. Pass 5: for each row group, emit only visible fields; skip rows with no visible content.
60 ReadOnlyGridFormView rows advance by 1 regardless of original rowSpan The web renders every field with grid-row: N / span 1. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space.
62 Sort schema by (row, col) before grouping fields into row buckets The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by row and attaches section headers must first sort by (f["row"], f["col"]) — exactly like the web's sorted(key=lambda f: (f['row'], f['col'])). Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute (col, row) pixel offsets (e.g. GridFormView ZStack, canvasHeight()) are unaffected — sort order only matters when grouping by row for sequential rendering.

| 63 | @Attribute(.unique) must NOT carry an inline default value | A .unique key with a default (@Attribute(.unique) var serverId: Int = 0) stops the @Model macro from emitting a clean PersistentModel conformance. Symptom is misleading: the .modelContainer(for: [ … ]) array literal fails to type-check and Xcode reports "Cannot find '' in scope" on the other schema elements, not the offending one. Declare the unique key with no default (@Attribute(.unique) var serverId: Int) and set it in init, exactly like LocalFacility/LocalArea. (Bit us adding LocalScheduledInspection, July 2026.) | | 64 | Attach .fullScreenCover / .sheet to a stable view, NEVER to a Section | A Section inside a List is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing List/ScrollView/VStack/Group root instead — and that root must also outlive the data that drives the presentation (see rule 68). The dashboard scheduled cover lives on DashboardStatsView's ScrollView; MyInspectionsView puts the scheduled "Start" cover on its enclosing Group. | | 65 | CodingKeys stay plain camelCase (decoder uses .convertFromSnakeCase); request-body keys are raw snake_case | The shared JSONDecoder sets keyDecodingStrategy = .convertFromSnakeCase, converting JSON facility_handler_namefacilityHandlerName 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/APIIssueDetailLocalIssue.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 Ints), 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 Buttons 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:

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.