Files
JQC_iOS_App/JanitorialQC/Models/LocalScheduledInspection.swift
T

142 lines
6.1 KiB
Swift

// Models/LocalScheduledInspection.swift
// --------------------------------------
// SwiftData model for planned/recurring inspection assignments (phase36).
//
// Read-only reference data pulled from the server (GET /api/v1/scheduled-inspections)
// and refreshed by SyncManager.pullScheduledInspections() — never created or
// mutated on device. Surfaced in the "Scheduled" section on the Dashboard and
// My Inspections. Tapping "Start" opens the normal new-inspection flow with the
// facility + template preselected; the schedule lifecycle (fulfil / roll-forward)
// stays server-driven.
//
// Follows the same pattern as LocalFacility / LocalArea: a `.unique` serverId
// WITHOUT an inline default (a default on the unique key breaks @Model's
// PersistentModel conformance) and a full init(from:)/update(from:) pair.
import Foundation
import SwiftData
@Model
final class LocalScheduledInspection {
/// Server ID of the ScheduledInspection row — stable unique identity.
@Attribute(.unique) var serverId: Int
var facilityServerId: Int
var facilityName: String
var templateServerId: Int
var templateName: String
var inspectorId: Int?
var frequency: String // once | daily | weekly | monthly
var frequencyLabel: String
/// Raw server date string "YYYY-MM-DD" — sortable (ISO strings sort
/// chronologically) and the source for the parsed `nextDue`.
var dueDateString: String
var isOverdue: Bool
var notes: String?
/// Server ID of the inspection this schedule is a planned follow-up of
/// (phase45). Carried onto the `LocalInspection` as `parentServerId` when
/// the inspector starts it, so the run lands as a linked re-inspection
/// rather than an ordinary scheduled one.
///
/// Optional, so SwiftData migrates lightweight without a plan (rule 46);
/// nil for an ordinary schedule and for every row pulled from a
/// pre-phase45 server.
var parentInspectionServerId: Int?
/// Set on device the moment an inspection fulfilling this schedule is
/// submitted, so the SCHEDULED lists hide the row immediately — online or
/// offline — without waiting for the round trip.
///
/// Purely a display flag. The schedule lifecycle stays server-driven: the
/// next successful pull calls `update(from:)`, which clears this and writes
/// the authoritative `next_due_date`. If the submission never lands, the
/// server still reports the schedule as due and the row simply comes back —
/// self-healing.
///
/// This exists instead of deleting the row. A recurring schedule is
/// returned by the server *again* after it rolls forward, so deleting meant
/// delete-then-reinsert against an `@Attribute(.unique)` key on every
/// completion, and the reinserted row did not reliably carry the new due
/// date. Flagging keeps `update(from:)` — the path that has always worked —
/// as the only way a cached row's dates ever change.
///
/// Non-optional with an inline default, so SwiftData migrates lightweight
/// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall.
var fulfilledLocally: Bool = false
/// Manager-authored instructions for this occurrence, normalised.
///
/// The wire field, the server model attribute and the DB column are all
/// still `notes` — only the user-facing wording changed to "Instructions"
/// (web form label + both iPad surfaces). Renaming the stored property would
/// break `init(from:)`/`update(from:)` against `APIScheduledInspection.notes`
/// for no benefit, so the rename lives here.
///
/// Returns nil for nil, empty, or whitespace-only text so callers can guard
/// with a single `if let` instead of repeating the emptiness check.
/// Computed, not stored — no SwiftData schema change.
var instructions: String? {
guard let t = notes?.trimmingCharacters(in: .whitespacesAndNewlines),
!t.isEmpty
else { return nil }
return t
}
/// Last time this row was refreshed from the server pull.
var updatedAt: Date
/// Parsed due date for display. Computed properties are not persisted by
/// SwiftData; sort on `dueDateString` (not this) in @Query.
var nextDue: Date? {
Self.dateOnlyFormatter.date(from: dueDateString)
}
private static let dateOnlyFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f
}()
init(from api: APIScheduledInspection) {
self.serverId = api.id
self.facilityServerId = api.facilityId
self.facilityName = api.facilityName ?? ""
self.templateServerId = api.templateId
self.templateName = api.templateName ?? ""
self.inspectorId = api.inspectorId
self.frequency = api.frequency
self.frequencyLabel = api.frequencyLabel ?? ""
self.dueDateString = api.nextDueDate ?? ""
self.isOverdue = api.isOverdue
self.notes = api.notes
self.parentInspectionServerId = api.parentInspectionId
self.fulfilledLocally = false
self.updatedAt = Date()
}
func update(from api: APIScheduledInspection) {
self.facilityServerId = api.facilityId
self.facilityName = api.facilityName ?? ""
self.templateServerId = api.templateId
self.templateName = api.templateName ?? ""
self.inspectorId = api.inspectorId
self.frequency = api.frequency
self.frequencyLabel = api.frequencyLabel ?? ""
self.dueDateString = api.nextDueDate ?? ""
self.isOverdue = api.isOverdue
self.notes = api.notes
self.parentInspectionServerId = api.parentInspectionId
// The server is authoritative. Being returned by the pull at all means
// this schedule is live again — for a recurring one, on its NEXT
// occurrence — so any local "just did it" flag is stale by definition.
self.fulfilledLocally = false
self.updatedAt = Date()
}
}