diff --git a/JanitorialQC/Views/Dashboard/PhotoDiagnosticView.swift b/JanitorialQC/Views/Dashboard/PhotoDiagnosticView.swift new file mode 100644 index 0000000..40b1d2d --- /dev/null +++ b/JanitorialQC/Views/Dashboard/PhotoDiagnosticView.swift @@ -0,0 +1,483 @@ +// Views/Dashboard/PhotoDiagnosticView.swift +// ------------------------------------------ +// READ-ONLY diagnostic for the lost-photo defect (August 2026). +// +// Reports, per record, which photo fields never received a server path and +// whether the underlying JPEG is still recoverable on this device. It exists to +// size the problem BEFORE any fix ships, because the fix sequence is +// destructive if run in the wrong order: correcting cleanupOrphanedPhotos() +// deletes exactly the files this report is looking for. +// +// THIS VIEW MUST STAY READ-ONLY. It never calls context.save(), context.delete(), +// FileManager write/remove, or any APIClient method. It only fetches, reads +// files' existence, and formats text. Anything that repairs data belongs in a +// separate, explicitly-named screen — a diagnostic the user cannot trust to be +// safe is a diagnostic they will not run. +// +// ── What it looks for ──────────────────────────────────────────────────────── +// A photo is "lost" when a form field still holds the local sentinel +// ("local://") instead of a server path ("uploads/..."). APIClient +// .submitInspection() rewrites that sentinel to "" in the request body only — +// inspection.formData is left intact — so the device still knows which field +// the photo belonged to. That is what makes recovery possible, and what this +// report enumerates. +// +// ── File resolution ────────────────────────────────────────────────────────── +// Stored paths are ABSOLUTE and include the app container UUID, which iOS +// changes on update / reinstall / restore. So a stored path that no longer +// exists does NOT mean the file is gone: the same filename usually still exists +// under the current container. Filenames are UUIDs generated per save, so a +// basename match is unambiguous and safe to rely on. + +import SwiftUI +import SwiftData +import UIKit + +// MARK: - Model + +/// Where the JPEG actually is, independent of what the database claims. +enum PhotoFileState { + /// The stored absolute path resolves — nothing has moved. + case foundAtStoredPath + /// The stored path is stale (container UUID changed) but a file with the + /// same unique filename exists now. Recoverable; carries the live path. + case foundByFilename(String) + /// No file with that name anywhere under the photo directories. + case missing + + var isRecoverable: Bool { + if case .missing = self { return false } + return true + } +} + +/// How much damage has already been done for one missing photo. +enum PhotoLossSeverity: Int { + /// Submitted to the server with the field blank — the loss is live, and + /// only a PATCH can repair it. + case lostOnServer = 0 + /// Completed but still in the outbox: it will be submitted blank on the + /// next sync unless the fix lands first. + case willBeLostOnNextSync = 1 + /// Still a draft — nothing lost yet. + case draftNotYetSubmitted = 2 + + var label: String { + switch self { + case .lostOnServer: return "LOST ON SERVER" + case .willBeLostOnNextSync: return "WILL BE LOST" + case .draftNotYetSubmitted: return "DRAFT (safe)" + } + } + + var color: Color { + switch self { + case .lostOnServer: return .red + case .willBeLostOnNextSync: return .orange + case .draftNotYetSubmitted: return .secondary + } + } +} + +struct PhotoDiagnosticEntry: Identifiable { + let id = UUID() + + let kind: String // "Inspection" | "Issue" + let localId: String + let serverId: Int? // the row to PATCH, when already submitted + let title: String // template / issue description + let facilityName: String + let date: Date + + let fieldId: String? // nil for issue photos (no form field) + let storedPath: String + let fileState: PhotoFileState + /// PendingPhoto.uploadStatus, or nil when no row survives for this photo. + let uploadStatus: String? + let severity: PhotoLossSeverity +} + +// MARK: - View + +struct PhotoDiagnosticView: View { + + @Environment(\.modelContext) private var context + + @State private var entries: [PhotoDiagnosticEntry] = [] + @State private var failedPhotoCount = 0 + @State private var totalPhotoRows = 0 + @State private var scanned = false + @State private var copied = false + + private var lostOnServer: [PhotoDiagnosticEntry] { + entries.filter { $0.severity == .lostOnServer } + } + private var willBeLost: [PhotoDiagnosticEntry] { + entries.filter { $0.severity == .willBeLostOnNextSync } + } + private var drafts: [PhotoDiagnosticEntry] { + entries.filter { $0.severity == .draftNotYetSubmitted } + } + private var recoverable: [PhotoDiagnosticEntry] { + entries.filter { $0.fileState.isRecoverable } + } + private var unrecoverable: [PhotoDiagnosticEntry] { + entries.filter { !$0.fileState.isRecoverable } + } + + var body: some View { + List { + Section { + Label( + "This screen only reads. It does not upload, delete, " + + "repair, or modify anything.", + systemImage: "lock.shield" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + if !scanned { + Section { + HStack { + ProgressView() + Text("Scanning…").padding(.leading, 8) + } + } + } else { + summarySection + if !entries.isEmpty { + entrySection("Lost on Server", lostOnServer, + footer: "Submitted with the photo field blank. " + + "Repairing these needs a re-upload plus a PATCH.") + entrySection("Will Be Lost on Next Sync", willBeLost, + footer: "Still in the outbox. These are submitted " + + "blank unless the fix lands first.") + entrySection("Drafts", drafts, + footer: "Not submitted yet — nothing lost.") + } + copySection + } + } + .navigationTitle("Photo Diagnostic") + .navigationBarTitleDisplayMode(.inline) + .task { runScan() } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + scanned = false + runScan() + } label: { + Label("Rescan", systemImage: "arrow.clockwise") + } + } + } + } + + // ── Sections ────────────────────────────────────────────────────────── + + private var summarySection: some View { + Section("Summary") { + row("Photos affected", "\(entries.count)", + tint: entries.isEmpty ? .green : .red) + row("Already lost on server", "\(lostOnServer.count)", + tint: lostOnServer.isEmpty ? .secondary : .red) + row("Will be lost on next sync", "\(willBeLost.count)", + tint: willBeLost.isEmpty ? .secondary : .orange) + row("Still recoverable (file on device)", "\(recoverable.count)", + tint: recoverable.isEmpty ? .secondary : .green) + row("File gone — unrecoverable", "\(unrecoverable.count)", + tint: unrecoverable.isEmpty ? .secondary : .red) + + Divider() + row("Photo upload rows total", "\(totalPhotoRows)", tint: .secondary) + row("Rows marked failed", "\(failedPhotoCount)", + tint: failedPhotoCount == 0 ? .secondary : .orange) + + if entries.isEmpty { + Label("No missing photos found on this device.", + systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + } + } + } + + @ViewBuilder + private func entrySection(_ title: String, + _ list: [PhotoDiagnosticEntry], + footer: String) -> some View { + if !list.isEmpty { + Section { + ForEach(list) { e in entryRow(e) } + } header: { + Text("\(title) (\(list.count))") + } footer: { + Text(footer) + } + } + } + + private func entryRow(_ e: PhotoDiagnosticEntry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(e.severity.label) + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(e.severity.color.opacity(0.15)) + .foregroundStyle(e.severity.color) + .clipShape(Capsule()) + Spacer() + if let sid = e.serverId { + Text("server #\(sid)") + .font(.caption2).foregroundStyle(.secondary) + } else { + Text("not on server") + .font(.caption2).foregroundStyle(.secondary) + } + } + + Text("\(e.kind): \(e.title)").font(.callout.bold()) + Text(e.facilityName).font(.caption).foregroundStyle(.secondary) + Text(e.date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2).foregroundStyle(.secondary) + + if let fid = e.fieldId { + Text("Field ID: \(fid)").font(.caption2).foregroundStyle(.secondary) + } + Text("Upload status: \(e.uploadStatus ?? "no record")") + .font(.caption2).foregroundStyle(.secondary) + + switch e.fileState { + case .foundAtStoredPath: + Label("File present at stored path — recoverable", + systemImage: "checkmark.circle") + .font(.caption2).foregroundStyle(.green) + case .foundByFilename: + Label("Stored path stale (container changed) — file found by " + + "name, recoverable", systemImage: "arrow.triangle.2.circlepath") + .font(.caption2).foregroundStyle(.orange) + case .missing: + Label("File not on this device — unrecoverable", + systemImage: "exclamationmark.triangle") + .font(.caption2).foregroundStyle(.red) + } + } + .padding(.vertical, 2) + } + + private var copySection: some View { + Section { + Button { + UIPasteboard.general.string = textReport() + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false } + } label: { + Label(copied ? "Copied" : "Copy Full Report", + systemImage: copied ? "checkmark" : "doc.on.doc") + } + } footer: { + Text("Copies a plain-text version, including full file paths, for " + + "sharing or for driving a recovery pass.") + } + } + + private func row(_ label: String, _ value: String, tint: Color) -> some View { + HStack { + Text(label) + Spacer() + Text(value).bold().foregroundStyle(tint) + } + .font(.callout) + } + + // ── Scan (read-only) ────────────────────────────────────────────────── + + private func runScan() { + // Fetch-all + filter in Swift — no #Predicate (CLAUDE.md rule 3), + // and every `try?` parenthesised before `??` (rule 25). + let inspections = (try? context.fetch(FetchDescriptor())) ?? [] + let issues = (try? context.fetch(FetchDescriptor())) ?? [] + let photos = (try? context.fetch(FetchDescriptor())) ?? [] + let facilities = (try? context.fetch(FetchDescriptor())) ?? [] + let templates = (try? context.fetch(FetchDescriptor())) ?? [] + + totalPhotoRows = photos.count + failedPhotoCount = photos.filter { $0.uploadStatus == "failed" }.count + + var facilityName: [Int: String] = [:] + for f in facilities { facilityName[f.serverId] = f.name } + var templateName: [Int: String] = [:] + for t in templates { templateName[t.serverId] = t.name } + + // basename -> live path, for re-resolving stale container paths. + let diskIndex = buildDiskIndex() + + // Photo rows keyed by the file they point at, so a form field can be + // matched to its upload record. + var photoByPath: [String: PendingPhoto] = [:] + for p in photos { photoByPath[p.localFilePath] = p } + + var found: [PhotoDiagnosticEntry] = [] + + // ── Inspections: form fields still holding the local:// sentinel ── + for insp in inspections { + for (fieldId, value) in insp.formData { + guard let s = value as? String, s.hasPrefix("local://") else { continue } + let path = String(s.dropFirst("local://".count)) + + let severity: PhotoLossSeverity + if insp.status == "draft" { + severity = .draftNotYetSubmitted + } else if insp.serverId != nil || insp.syncStatus == "synced" { + severity = .lostOnServer + } else { + severity = .willBeLostOnNextSync + } + + found.append(PhotoDiagnosticEntry( + kind: "Inspection", + localId: insp.localId, + serverId: insp.serverId, + title: templateName[insp.templateServerId] + ?? "Template #\(insp.templateServerId)", + facilityName: facilityName[insp.facilityServerId] + ?? "Facility #\(insp.facilityServerId)", + date: insp.inspectionDate, + fieldId: fieldId, + storedPath: path, + fileState: resolve(path, diskIndex), + uploadStatus: photoByPath[path]?.uploadStatus, + severity: severity + )) + } + } + + // ── Issues: local photos that never produced a server path ──────── + // Same defect, different surface. processIssueQueue clears + // photoLocalPaths only after a successful submit, so a synced issue + // still holding local paths with no server paths lost its evidence. + for issue in issues { + guard !issue.photoLocalPaths.isEmpty else { continue } + let missingServerSide = issue.photoServerPaths.count < issue.photoLocalPaths.count + guard missingServerSide else { continue } + + let severity: PhotoLossSeverity + if issue.syncStatus == "synced" || issue.serverId != nil { + severity = .lostOnServer + } else if issue.syncStatus == "failed" { + severity = .willBeLostOnNextSync + } else { + severity = .willBeLostOnNextSync + } + + for path in issue.photoLocalPaths { + // A path already mirrored server-side is fine — skip it. + if let p = photoByPath[path], p.uploadStatus == "uploaded" { continue } + + found.append(PhotoDiagnosticEntry( + kind: "Issue", + localId: issue.localId, + serverId: issue.serverId, + title: issue.issueDescription.isEmpty + ? "(no description)" + : String(issue.issueDescription.prefix(60)), + facilityName: facilityName[issue.facilityServerId] + ?? "Facility #\(issue.facilityServerId)", + date: issue.createdAt, + fieldId: nil, + storedPath: path, + fileState: resolve(path, diskIndex), + uploadStatus: photoByPath[path]?.uploadStatus, + severity: severity + )) + } + } + + entries = found.sorted { + if $0.severity.rawValue != $1.severity.rawValue { + return $0.severity.rawValue < $1.severity.rawValue + } + return $0.date > $1.date + } + scanned = true + } + + /// Map of filename -> current absolute path for every file under the photo + /// directories. Filenames are per-save UUIDs, so collisions are not a + /// practical concern and a basename match identifies a file uniquely. + private func buildDiskIndex() -> [String: String] { + let fm = FileManager.default + guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first + else { return [:] } + + var index: [String: String] = [:] + // Both directories the app writes to today. Enumerating rather than + // assuming, so a file left by an older build is still found. + for sub in ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"] { + let dir = docs.appendingPathComponent(sub, isDirectory: true) + guard let files = try? fm.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil + ) else { continue } + for f in files { index[f.lastPathComponent] = f.path } + } + return index + } + + private func resolve(_ storedPath: String, + _ diskIndex: [String: String]) -> PhotoFileState { + if FileManager.default.fileExists(atPath: storedPath) { + return .foundAtStoredPath + } + let name = URL(fileURLWithPath: storedPath).lastPathComponent + if let live = diskIndex[name] { + return .foundByFilename(live) + } + return .missing + } + + // ── Text report ─────────────────────────────────────────────────────── + + private func textReport() -> String { + var out = """ + JQC PHOTO DIAGNOSTIC (read-only) + Generated: \(Date().formatted(date: .abbreviated, time: .standard)) + Server: \(ServerConfig.current) + + SUMMARY + Photos affected .................. \(entries.count) + Already lost on server ......... \(lostOnServer.count) + Will be lost on next sync ...... \(willBeLost.count) + Drafts (safe) .................. \(drafts.count) + Recoverable (file present) ..... \(recoverable.count) + Unrecoverable (file gone) ...... \(unrecoverable.count) + Photo rows total ............... \(totalPhotoRows) + Rows marked failed ............. \(failedPhotoCount) + + DETAIL + + """ + for e in entries { + let state: String + switch e.fileState { + case .foundAtStoredPath: state = "file OK at stored path" + case .foundByFilename(let p): state = "file found by name -> \(p)" + case .missing: state = "FILE MISSING" + } + out += """ + [\(e.severity.label)] \(e.kind) \(e.serverId.map { "server #\($0)" } ?? "(unsent)") + title : \(e.title) + facility : \(e.facilityName) + date : \(e.date.formatted(date: .abbreviated, time: .shortened)) + localId : \(e.localId) + fieldId : \(e.fieldId ?? "-") + storedPath : \(e.storedPath) + upload : \(e.uploadStatus ?? "no record") + fileState : \(state) + + """ + } + if entries.isEmpty { out += "(none)\n" } + return out + } +} diff --git a/JanitorialQC/Views/Dashboard/SettingsView.swift b/JanitorialQC/Views/Dashboard/SettingsView.swift index 2d0eb18..f8222c2 100644 --- a/JanitorialQC/Views/Dashboard/SettingsView.swift +++ b/JanitorialQC/Views/Dashboard/SettingsView.swift @@ -89,6 +89,25 @@ struct SettingsView: View { } } + // Read-only investigation aid for the lost-photo defect. Placed + // above Cache deliberately: "Clear Reference Cache" sits next to it + // and the diagnostic must be run BEFORE anything that touches + // stored data, while the evidence is still intact. + Section("Diagnostics") { + NavigationLink { + PhotoDiagnosticView() + } label: { + // Not a photo.badge.* symbol — those are not universally + // available on iOS 17 (CLAUDE.md rule 41). + Label("Photo Diagnostic", systemImage: "doc.text.magnifyingglass") + } + Text("Reports inspection and issue photos that never reached the " + + "server, and whether the original file is still on this " + + "device. Read-only — changes nothing.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Cache") { Button { showClearCacheAlert = true