The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
181 lines
8.3 KiB
Swift
181 lines
8.3 KiB
Swift
//
|
|
// SchemaSkipScanner.swift
|
|
// Reference code copied from the indie-diag skill (2026-07-06 addition),
|
|
// adapted for Workouts.
|
|
//
|
|
// Answers a question `NSMetadataQuery` can't: of the documents that are fully
|
|
// synced and locally present, how many is this build currently *skipping*
|
|
// because their `schemaVersion` is newer than it understands (IndieSync's
|
|
// `VersionedDocument.isReadable` forward gate)? A skipped document is a
|
|
// healthy, expected state — the data is safe and fully synced, just written by
|
|
// a newer app version on another device — and updating the app recovers it.
|
|
//
|
|
// ADAPTATION FROM THE REFERENCE: the reference scanner takes a single
|
|
// `currentSchemaVersion: Int`. Workouts has TWO document types with
|
|
// INDEPENDENT version counters (`RoutineDocument.currentSchemaVersion` and
|
|
// `WorkoutDocument.currentSchemaVersion`), so one global ceiling can't classify
|
|
// both correctly — a Routine written at a version the workout counter has already
|
|
// reached would be misjudged. The forward-gate ceiling therefore has to be
|
|
// resolved per file (its location under `Documents/` tells us its type), so the
|
|
// scanner takes a `CeilingResolver` closure. A single-Int convenience init is
|
|
// kept so the file stays drop-in for single-document-type apps. Deliberately
|
|
// never triggers a download: callers filter to locally-present documents first
|
|
// (see `DiagnosticsCoordinator.generate()`).
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Outcome of probing one document's schema version.
|
|
public enum SchemaProbeState: Sendable, Equatable {
|
|
/// `schemaVersion` is at or below what this build understands.
|
|
case readable(version: Int)
|
|
/// `schemaVersion` is newer than this build understands — the app's
|
|
/// `isReadable` forward gate skips this document. An app update
|
|
/// recovers it; the data is intact.
|
|
case skipped(version: Int)
|
|
/// Decoded as JSON but no schema version field was found (e.g. a
|
|
/// tombstone stub or other non-versioned file in the container).
|
|
case noVersionField
|
|
/// The file couldn't be read or parsed at all.
|
|
case unreadable(description: String)
|
|
}
|
|
|
|
/// Probe result for one document.
|
|
public struct SchemaProbeResult: Sendable, Identifiable {
|
|
public var id: URL { url }
|
|
public let url: URL
|
|
public let fileName: String
|
|
public let state: SchemaProbeState
|
|
}
|
|
|
|
/// Point-in-time result of scanning locally-available documents.
|
|
///
|
|
/// Only documents that were actually probed appear here — evicted
|
|
/// documents are excluded upstream (never downloaded just to probe), so
|
|
/// `skippedCount` is a floor, not a guarantee, whenever
|
|
/// `DocumentSyncSnapshot.evictedCount` is nonzero.
|
|
public struct SchemaSkipSnapshot: Sendable {
|
|
public let results: [SchemaProbeResult]
|
|
public let currentSchemaVersion: Int
|
|
public let generatedAt: Date
|
|
|
|
public var probedCount: Int { results.count }
|
|
public var skippedCount: Int {
|
|
results.count { if case .skipped = $0.state { return true } else { return false } }
|
|
}
|
|
public var skipped: [SchemaProbeResult] {
|
|
results.filter { if case .skipped = $0.state { return true } else { return false } }
|
|
}
|
|
/// Highest schema version seen among skipped documents — i.e. the
|
|
/// newest app version that has written into this container. Useful in
|
|
/// support copy ("another device is running a newer version").
|
|
public var newestSkippedVersion: Int? {
|
|
skipped.compactMap { if case .skipped(let v) = $0.state { return v } else { return nil } }.max()
|
|
}
|
|
}
|
|
|
|
/// Probes documents for their `schemaVersion` and counts the ones this
|
|
/// build would skip. A plain `actor` so the coordinated file reads block off
|
|
/// the caller's thread and a wedged iCloud account can't freeze the UI.
|
|
public actor SchemaSkipScanner {
|
|
/// Pulls the schema version out of a document's raw bytes. Return nil
|
|
/// if the data has no version field.
|
|
public typealias VersionExtractor = @Sendable (Data) -> Int?
|
|
|
|
/// Resolves the newest schema version THIS build can read for the document at
|
|
/// `url` — its forward-gate ceiling. Workouts resolves this from the file's
|
|
/// location under `Documents/` (Splits/ vs Workouts/); see the file header.
|
|
public typealias CeilingResolver = @Sendable (URL) -> Int
|
|
|
|
private let ceilingForURL: CeilingResolver
|
|
private let extractVersion: VersionExtractor
|
|
/// Representative "newest schema this build understands" (the max ceiling),
|
|
/// kept only for `SchemaSkipSnapshot.currentSchemaVersion`'s informational use.
|
|
private let representativeCeiling: Int
|
|
|
|
/// Per-URL ceiling — the form Workouts uses (two document types, two counters).
|
|
/// - Parameters:
|
|
/// - ceilingForURL: newest readable version for the document at a given URL.
|
|
/// - representativeCeiling: a single number for the snapshot's informational
|
|
/// field — pass the max of the app's document-type ceilings.
|
|
/// - extractVersion: override when documents don't match the default layouts.
|
|
public init(
|
|
ceilingForURL: @escaping CeilingResolver,
|
|
representativeCeiling: Int,
|
|
extractVersion: VersionExtractor? = nil
|
|
) {
|
|
self.ceilingForURL = ceilingForURL
|
|
self.representativeCeiling = representativeCeiling
|
|
self.extractVersion = extractVersion ?? Self.defaultExtractor
|
|
}
|
|
|
|
/// Single global ceiling — the reference package's original signature, kept so
|
|
/// this file stays drop-in for single-document-type apps.
|
|
public init(currentSchemaVersion: Int, extractVersion: VersionExtractor? = nil) {
|
|
self.init(
|
|
ceilingForURL: { _ in currentSchemaVersion },
|
|
representativeCeiling: currentSchemaVersion,
|
|
extractVersion: extractVersion
|
|
)
|
|
}
|
|
|
|
/// Probe the given document URLs. Pass only locally-present documents
|
|
/// (filter a `DocumentSyncSnapshot` down to states that imply a full
|
|
/// local copy) — this scanner reads whatever it's handed, and reading
|
|
/// an evicted document would trigger a full download.
|
|
public func scan(urls: [URL]) -> SchemaSkipSnapshot {
|
|
SchemaSkipSnapshot(
|
|
results: urls.map(probe),
|
|
currentSchemaVersion: representativeCeiling,
|
|
generatedAt: Date()
|
|
)
|
|
}
|
|
|
|
private func probe(_ url: URL) -> SchemaProbeResult {
|
|
let fileName = url.lastPathComponent
|
|
|
|
// Coordinated read, same as the sync engine's document reads — a
|
|
// plain Data(contentsOf:) can race an in-flight iCloud update.
|
|
var coordinationError: NSError?
|
|
var data: Data?
|
|
var readErrorDescription: String?
|
|
NSFileCoordinator(filePresenter: nil).coordinate(
|
|
readingItemAt: url, options: .withoutChanges, error: &coordinationError
|
|
) { actualURL in
|
|
do { data = try Data(contentsOf: actualURL) }
|
|
catch { readErrorDescription = error.localizedDescription }
|
|
}
|
|
|
|
guard let data else {
|
|
let description = coordinationError?.localizedDescription
|
|
?? readErrorDescription
|
|
?? "Unknown read error"
|
|
return SchemaProbeResult(url: url, fileName: fileName, state: .unreadable(description: description))
|
|
}
|
|
guard let version = extractVersion(data) else {
|
|
return SchemaProbeResult(url: url, fileName: fileName, state: .noVersionField)
|
|
}
|
|
let ceiling = ceilingForURL(url)
|
|
let state: SchemaProbeState = version > ceiling
|
|
? .skipped(version: version)
|
|
: .readable(version: version)
|
|
return SchemaProbeResult(url: url, fileName: fileName, state: state)
|
|
}
|
|
|
|
/// Covers both canonical portfolio layouts: top-level `schemaVersion`
|
|
/// (IndieSync's `VersionedDocument`) and `meta.schemaVersion`
|
|
/// (time-series-data-model's Meta+Payload). Decodes only the version
|
|
/// field — never the full document, so a future schema can't trip it.
|
|
private static let defaultExtractor: VersionExtractor = { data in
|
|
struct TopLevel: Decodable { let schemaVersion: Int }
|
|
struct MetaWrapped: Decodable {
|
|
struct Meta: Decodable { let schemaVersion: Int }
|
|
let meta: Meta
|
|
}
|
|
let decoder = JSONDecoder()
|
|
if let doc = try? decoder.decode(TopLevel.self, from: data) { return doc.schemaVersion }
|
|
if let doc = try? decoder.decode(MetaWrapped.self, from: data) { return doc.meta.schemaVersion }
|
|
return nil
|
|
}
|
|
}
|