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
218 lines
8.3 KiB
Swift
218 lines
8.3 KiB
Swift
//
|
|
// WriteBacklog.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// One queued write: the latest pending version of a single document (or its
|
|
/// deletion), plus retry bookkeeping. `timestamp` is the document's `updatedAt`
|
|
/// (a delete's deletion stamp) — slot replacement and the import/reconcile
|
|
/// supersede checks key on it, newer wins.
|
|
struct PendingWrite: Codable, Sendable {
|
|
enum Payload: Codable, Sendable {
|
|
case routine(RoutineDocument)
|
|
case workout(WorkoutDocument)
|
|
case schedule(ScheduleDocument)
|
|
case delete(id: String, kind: String, livePath: String)
|
|
|
|
// PINNED KEY: the case was renamed `.split` → `.routine`, but its persisted
|
|
// JSON key stays `"split"` so a `PendingWrites.json` sidecar written by a
|
|
// previous run still decodes. `workout` / `schedule` / `delete` keep their
|
|
// default keys.
|
|
enum CodingKeys: String, CodingKey {
|
|
case routine = "split"
|
|
case workout
|
|
case schedule
|
|
case delete
|
|
}
|
|
}
|
|
|
|
var payload: Payload
|
|
var timestamp: Date
|
|
/// Paths this document previously lived at (month re-bucketing, replaced
|
|
/// pending versions, a delete that superseded a queued save) — best-effort
|
|
/// removed after the write lands so the id never survives at two paths.
|
|
var stalePaths: Set<String> = []
|
|
/// When the slot was first occupied — replacement keeps it, so the TTL
|
|
/// measures how long the user's intent has been stuck, not the last edit.
|
|
var enqueuedAt: Date
|
|
var attempts: Int = 0
|
|
var lastAttemptAt: Date?
|
|
/// Set when an attempt failed with an error retrying can't fix (disk full).
|
|
/// The drain keeps retrying regardless — conditions can change — but the
|
|
/// banner escalates immediately.
|
|
var faultMessage: String?
|
|
|
|
var documentID: String {
|
|
switch payload {
|
|
case .routine(let doc): doc.id
|
|
case .workout(let doc): doc.id
|
|
case .schedule(let doc): doc.id
|
|
case .delete(let id, _, _): id
|
|
}
|
|
}
|
|
|
|
/// Where a successful write lands; nil for deletes (they only remove).
|
|
var targetPath: String? {
|
|
switch payload {
|
|
case .routine(let doc): doc.relativePath
|
|
case .workout(let doc): doc.relativePath
|
|
case .schedule(let doc): doc.relativePath
|
|
case .delete: nil
|
|
}
|
|
}
|
|
|
|
var nextAttemptAt: Date {
|
|
guard let lastAttemptAt else { return enqueuedAt }
|
|
return lastAttemptAt.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: attempts))
|
|
}
|
|
}
|
|
|
|
/// Depth-1 per-document write queue: at most one pending write per document id,
|
|
/// newest `updatedAt` wins the slot. Pure state + policy — no I/O, no clock of
|
|
/// its own — so the whole replacement/retry/TTL table is unit-testable.
|
|
/// `SyncEngine` owns the drain loop and persistence.
|
|
struct WriteBacklog: Codable, Sendable {
|
|
private(set) var slots: [String: PendingWrite] = [:]
|
|
|
|
var isEmpty: Bool { slots.isEmpty }
|
|
var count: Int { slots.count }
|
|
|
|
/// Highest attempt count across pending writes — drives the banner tiers.
|
|
var maxAttempts: Int { slots.values.map(\.attempts).max() ?? 0 }
|
|
|
|
/// The oldest entry's fault, if any entry has hit an unrecoverable error.
|
|
var firstFaultMessage: String? {
|
|
entries.compactMap(\.faultMessage).first
|
|
}
|
|
|
|
/// All pending writes, oldest slot first.
|
|
var entries: [PendingWrite] {
|
|
slots.values.sorted { $0.enqueuedAt < $1.enqueuedAt }
|
|
}
|
|
|
|
func pendingWrite(for id: String) -> PendingWrite? { slots[id] }
|
|
|
|
/// Newer-wins slot replacement. An incoming write at least as new as the
|
|
/// occupant takes the slot (equal = the latest local re-save of the same
|
|
/// state; content-identical, so last-in is right). It inherits the
|
|
/// occupant's retry bookkeeping — the backlog is failing for environmental
|
|
/// reasons, and resetting attempts on every edit would flap the banner —
|
|
/// plus its stale paths and its target path when the two differ (the
|
|
/// occupant was never written, but an earlier attempt may have landed).
|
|
/// An incoming write strictly older than the occupant is dropped.
|
|
mutating func enqueue(_ write: PendingWrite) {
|
|
let id = write.documentID
|
|
guard let existing = slots[id] else {
|
|
slots[id] = write
|
|
return
|
|
}
|
|
guard write.timestamp >= existing.timestamp else { return }
|
|
var merged = write
|
|
merged.stalePaths.formUnion(existing.stalePaths)
|
|
if let oldPath = existing.targetPath, oldPath != merged.targetPath {
|
|
merged.stalePaths.insert(oldPath)
|
|
}
|
|
merged.enqueuedAt = existing.enqueuedAt
|
|
merged.attempts = existing.attempts
|
|
merged.lastAttemptAt = existing.lastAttemptAt
|
|
merged.faultMessage = existing.faultMessage
|
|
slots[id] = merged
|
|
}
|
|
|
|
mutating func markFailed(id: String, at now: Date, fault: String? = nil) {
|
|
guard var entry = slots[id] else { return }
|
|
entry.attempts += 1
|
|
entry.lastAttemptAt = now
|
|
if let fault { entry.faultMessage = fault }
|
|
slots[id] = entry
|
|
}
|
|
|
|
/// Remove after a successful write — replacement-safe: a newer write that
|
|
/// slotted in while this one was in flight stays queued.
|
|
mutating func resolve(id: String, ifTimestampAtMost timestamp: Date) {
|
|
guard let entry = slots[id], entry.timestamp <= timestamp else { return }
|
|
slots[id] = nil
|
|
}
|
|
|
|
mutating func removeAll() { slots.removeAll() }
|
|
|
|
/// Drop entries stuck longer than `ttl` — applied when a persisted backlog
|
|
/// is loaded, so a slot from a long-dead run can't drain stale state into a
|
|
/// container that has long since moved on.
|
|
mutating func pruneExpired(now: Date, ttl: TimeInterval = 24 * 60 * 60) {
|
|
slots = slots.filter { now.timeIntervalSince($0.value.enqueuedAt) < ttl }
|
|
}
|
|
|
|
/// The next entry whose backoff has elapsed, oldest slot first.
|
|
func nextDue(at now: Date) -> PendingWrite? {
|
|
entries.first { $0.nextAttemptAt <= now }
|
|
}
|
|
|
|
func earliestNextAttempt() -> Date? {
|
|
slots.values.map(\.nextAttemptAt).min()
|
|
}
|
|
|
|
/// Backoff schedule: three quick silent retries (tier 0, ~14 s total), then
|
|
/// calm long retries capped at 5 minutes.
|
|
static func retryDelay(afterAttempts attempts: Int) -> TimeInterval {
|
|
switch attempts {
|
|
case ..<1: 0
|
|
case 1: 2
|
|
case 2: 4
|
|
case 3: 8
|
|
case 4: 30
|
|
case 5: 60
|
|
case 6: 120
|
|
default: 300
|
|
}
|
|
}
|
|
|
|
/// Attempts after which the calm "changes pending" banner shows (~14 s of
|
|
/// silent retries have failed).
|
|
static let tier1AttemptThreshold = 3
|
|
/// Attempts after which the backlog is treated as a fault even without an
|
|
/// unrecoverable error (~10 minutes of failures).
|
|
static let tier2AttemptThreshold = 8
|
|
}
|
|
|
|
/// UI-facing rollup of the backlog — the banner tiers.
|
|
enum WriteQueueState: Equatable {
|
|
/// Nothing queued.
|
|
case idle
|
|
/// Writes queued inside the silent tier-0 window — no UI.
|
|
case pending
|
|
/// Tier 1: writes keep failing; calm "changes pending" banner.
|
|
case retrying
|
|
/// Tier 2: unrecoverable error or backoff exhaustion; prominent banner
|
|
/// pointing at Diagnostics.
|
|
case fault(String)
|
|
}
|
|
|
|
/// Load/persist the backlog sidecar file. Failures are non-fatal by design: an
|
|
/// unreadable file loads as an empty backlog (worst case, queued-but-unwritten
|
|
/// edits from a previous run are lost — the same loss the queue exists to
|
|
/// narrow, never corruption), and a failed persist leaves the previous file in
|
|
/// place for the in-memory backlog to overwrite on the next change.
|
|
enum WriteBacklogFile {
|
|
static func load(from url: URL) -> WriteBacklog {
|
|
guard let data = try? Data(contentsOf: url),
|
|
let backlog = try? JSONDecoder().decode(WriteBacklog.self, from: data) else {
|
|
return WriteBacklog()
|
|
}
|
|
return backlog
|
|
}
|
|
|
|
static func save(_ backlog: WriteBacklog, to url: URL) {
|
|
if backlog.isEmpty {
|
|
try? FileManager.default.removeItem(at: url)
|
|
return
|
|
}
|
|
guard let data = try? JSONEncoder().encode(backlog) else { return }
|
|
try? data.write(to: url, options: .atomic)
|
|
}
|
|
}
|