Jul 30 - Update iPad - inspector can re-inspect and create follow-up

This commit is contained in:
Nguyen Ngo
2026-07-30 16:16:15 -04:00
parent b7990cc9ba
commit dac7e6c597
16 changed files with 961 additions and 19 deletions
+23 -2
View File
@@ -195,7 +195,8 @@ PendingPhoto.self, SyncQueueEntry.self
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) |
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
| `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` |
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
@@ -252,7 +253,9 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e
5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response).
6. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
6. **`pullFollowUpRequests`** — fetches `GET /api/v1/inspections?follow_up_required=true`. Upserts `LocalFollowUpRequest` by `serverId`, deletes rows the server no longer returns, and mirrors `followUpRequired`/`followUpNote` onto the matching `LocalInspection` so the history badge agrees with the card. Best-effort — never blocks the pipeline. Runs after `processInspectionQueue`, so the section clears on the same sync that submits the re-inspection.
7. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
### Server-pulled issue identification
@@ -310,6 +313,8 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
| `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` |
| `createScheduledFollowUp` | `POST /api/v1/scheduled-inspections/follow-up` | Plans a follow-up re-inspection for a later date (phase45). Body is only `parent_inspection_id` + `due_date` (`yyyy-MM-dd`) + optional `notes` — the server derives facility/template/assignee from the parent. Idempotent: a retry re-dates the existing active follow-up. Inspector-writable (deliberate divergence — the web is `@project_manager_required`). Online-only; see rule 80 |
| `fetchFollowUpRequests` | `GET /api/v1/inspections?follow_up_required=true&limit=200` | Outstanding follow-up requests; same response shape as `fetchInspectionHistory`. Limit is the endpoint max on purpose — a follow-up raised on a months-old inspection must still appear. Pulled into `LocalFollowUpRequest`. See rule 78 for what the server-side filter must mean |
| `updateIssueHandler` | `PATCH /api/v1/issues/<id>/handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
### `APIAssignedIssue` fields
@@ -467,6 +472,19 @@ else { continue }
`CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`.
The **Follow-up Requested** card / section (July 2026) is the second trigger, and reaches the same view with `parentServerId` plus `preFillFollowUpNote` and `preFillParentFormDataJSON`.
**History detail** (`HistoryDetailView`) is the third and fourth: a **Re-inspect Now** toolbar button (immediate, passing this response's own answers as `preFillParentFormDataJSON` — history is served from the API, so the parent is usually not local) and **Schedule Follow-up**, which plans it for a later date via `createScheduledFollowUp`. The scheduled row then starts as a linked re-inspection because it carries `parentInspectionServerId`. See rule 80.
### Parent pre-fill — two sources
`StartInspectionView.startInspection()` copies the parent's answers forward, excluding `rating`, `pass_fail`, `image`, `signature` so every scoreable item is re-evaluated fresh and the parent's photos stay with the parent. This mirrors the web's `inspections.execute` prefill.
`resolvedParentFormData()` resolves the source in order:
1. The local `LocalInspection` with a matching `serverId` — the `CompletedInspectionView` path, where the inspector just finished it on this iPad. Used only when it actually holds values, so an empty local shell can't shadow source 2.
2. `preFillParentFormDataJSON` — snapshotted from the server onto `LocalFollowUpRequest.parentFormDataJSON` at pull time. This is the follow-up-request path. See rule 79.
### followUpRequired clearing — three points
1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
@@ -706,6 +724,9 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
| 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). |
| 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. |
| 78 | **"Outstanding follow-up" is three conditions, not one — `follow_up_required` alone is not the definition** | Every web surface (`inspections.list` / `reports` `status_filter == 'follow_up'`, `stats.pending_followups`) means **flagged AND `status == 'completed'` AND `~follow_ups.any()`**. The reason the third clause exists: the **web execute route never clears `follow_up_required` on the parent** — it only stops *listing* the parent once a child re-inspection exists. (The mobile POST path *does* clear the parent flag, `app/api/inspections.py`, so only web-completed re-inspections leave a stale flag.) The first cut of the API's `?follow_up_required=true` filter matched the flag alone, which would have returned follow-ups already satisfied on the web — and on the iPad those rows are **undismissable**: `pullFollowUpRequests()` keeps receiving them, `update(from:)` deliberately resets `fulfilledLocally = false` (the server is authoritative), so FOLLOW-UP REQUESTED would never clear and the only way out is a duplicate re-inspection. Fixed server-side so one definition serves every client. Never re-narrow this filter to the bare flag, and never "fix" a stuck row on the client — `fulfilledLocally` is a display flag, not state. |
| 79 | **A re-inspection's parent is usually NOT on the device — prefill must fall back to the cached snapshot, and never prefill without the template schema** | `startInspection()`'s prefill originally matched only a local `LocalInspection` by `serverId`. That works for `CompletedInspectionView` (the inspector just finished it here) but **not for a follow-up raised on the web**: that parent synced long ago and is routinely absent (reinstall, second iPad, follow-up raised weeks later — the same premise `LocalFollowUpRequest` exists for). The lookup found nothing, the whole block silently no-opped, and the form opened blank where the web pre-fills it. Fix: `LocalFollowUpRequest.parentFormDataJSON` caches the parent's answers at pull time — free, because `GET /api/v1/inspections` already returns `form_data` on every row via `_inspection_payload`, so there is no extra request and prefill works offline. Store `formDataRaw.mapValues(\.anyValue)`, **not** `formValues`: the latter joins arrays into `"a, b"`, which would be written back as one bogus string. Second trap, only reachable once prefill actually runs: the exclude set is derived from the template schema, so an unresolved schema (`?? []`) yields **no exclusions and copies everything** — including the parent's `image` paths, attaching its photos as this inspection's evidence. Guard on `!schema.isEmpty` and copy nothing instead. |
| 80 | **"Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it** | History detail (`HistoryDetailView`) carries three toolbar actions: **Re-inspect Now** (immediate, opens the linked re-inspection), **Schedule Follow-up** (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a `ScheduledInspection` row that only the server can create — there is no local record to queue, so the button is `.disabled(!sync.isOnline)` and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: `pullScheduledInspections()` deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is `scheduled_inspections.parent_inspection_id` (phase45): both start paths inherit it onto the inspection (`ScheduledStartTarget.parentServerId` on iPad, the web's `scheduled_inspections.start`), **and** the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's `@project_manager_required`, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. |
---