crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit f851ea71b5e384b526455128cb1f2b299ce513bd
parent 9fbe2c04ff9fa781ed42a797d8160be57d3232ae
Author: Michael Camilleri <[email protected]>
Date:   Sat, 23 May 2026 17:49:42 +0900

Surface NYT upgrade outcome in the diagnostics log

The earlier 'open puzzle every time, no fix' loop hid which branch of
NYTPuzzleUpgrader.apply was firing — fetch, mismatch, or transient failure all
looked the same on device without Console.app.

SyncMonitor is essentially a generic event log with sync-specific error
tracking bolted on; CrossmateApp already calls syncMonitor.note(...) for
unrelated breadcrumbs. This commit pulls the ring buffer out into a new
EventLog (with EventLogEntry) and has SyncMonitor compose it. Its note(_:)
forwards to the log so the ~50 existing callers compile unchanged.
DiagnosticsView and ShareDiagnosticsView read entries from the EventLog
environment instead, and the navigation title drops to 'Diagnostics' now that
events from anywhere in the app surface here.

NYTPuzzleUpgrader.apply now returns its Outcome (@discardableResult so the
existing upgrade() tests are unaffected). The CrossmateApp open- puzzle path
appends one of three entries to the event log depending on which branch fires,
with the level set to info, warn or error.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 11++++++++++-
MCrossmate/Services/AppServices.swift | 5++++-
MCrossmate/Services/DebuggingMonitors.swift | 46++++++++++++++++++++++++++++++++--------------
MCrossmate/Services/NYTPuzzleUpgrader.swift | 4+++-
MCrossmate/Views/DiagnosticsView.swift | 16+++++++++-------
5 files changed, 58 insertions(+), 24 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -23,6 +23,7 @@ struct CrossmateApp: App { .environment(services.inputMonitor) .environment(services.announcements) .environment(services.syncMonitor) + .environment(services.eventLog) .environment(\.syncEngine, services.syncEngine) .environment(services.nytAuth) .environment(\.nytPuzzleFetcher, services.nytFetcher) @@ -451,9 +452,17 @@ private struct PuzzleDisplayView: View { if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) { loadingMessage = "Updating puzzle..." let fetcher = services.nytFetcher - await NYTPuzzleUpgrader.apply(plan: plan, store: store) { date in + let outcome = await NYTPuzzleUpgrader.apply(plan: plan, store: store) { date in try await fetcher.fetchPuzzle(for: date) } + switch outcome { + case .upgraded: + services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] applied") + case .mismatched: + services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] structural mismatch — clue not updated", level: "warn") + case .failed(let error): + services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] fetch failed: \(error)", level: "error") + } } let (game, mutator) = try store.loadGame(id: gameID) let newSession = PlayerSession( diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -45,6 +45,7 @@ final class AppServices { let persistence: PersistenceController let store: GameStore let syncEngine: SyncEngine + let eventLog: EventLog let syncMonitor: SyncMonitor let nytAuth: NYTAuthService let driveMonitor: DriveMonitor @@ -106,7 +107,9 @@ final class AppServices { self.persistence = persistence let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence) self.syncEngine = syncEngine - self.syncMonitor = SyncMonitor() + let eventLog = EventLog() + self.eventLog = eventLog + self.syncMonitor = SyncMonitor(log: eventLog) self.nytAuth = NYTAuthService() self.driveMonitor = DriveMonitor() self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -174,13 +174,35 @@ final class PerformanceMonitor { } } -struct SyncDiagnosticEntry: Identifiable, Sendable { +struct EventLogEntry: Identifiable, Sendable { let id = UUID() let timestamp: Date let level: String let message: String } +/// Generic in-app event log. Anything that wants to leave a breadcrumb the +/// user can read in the diagnostics screen calls `note(_:)`. SyncMonitor and +/// other producers compose this — they don't subclass it. +@MainActor +@Observable +final class EventLog { + private(set) var entries: [EventLogEntry] = [] + + private let maxEntries = 300 + + func note(_ message: String, level: String = "info") { + append(level: level, message) + } + + fileprivate func append(level: String, _ message: String) { + entries.append(EventLogEntry(timestamp: Date(), level: level, message: message)) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } +} + @MainActor @Observable final class SyncMonitor { @@ -189,13 +211,16 @@ final class SyncMonitor { private(set) var lastErrorDomain: String? private(set) var lastErrorCode: Int? private(set) var lastErrorDescription: String? - private(set) var entries: [SyncDiagnosticEntry] = [] private(set) var snapshot: SyncEngine.DiagnosticSnapshot? - private let maxEntries = 300 + private let log: EventLog + + init(log: EventLog) { + self.log = log + } func recordStart(_ phase: String) { - append(level: "info", "starting \(phase)") + log.append(level: "info", "starting \(phase)") } func recordSuccess(_ phase: String) { @@ -207,7 +232,7 @@ final class SyncMonitor { if lastErrorPhase == phase { clearLastError() } - append(level: "info", "\(phase) succeeded") + log.append(level: "info", "\(phase) succeeded") } func recordError(_ phase: String, _ error: Error) { @@ -220,11 +245,11 @@ final class SyncMonitor { .map { "\($0.key)=\($0.value)" } .joined(separator: " | ") let message = "\(phase) failed: domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription) | userInfo: \(userInfoSummary)" - append(level: "error", message) + log.append(level: "error", message) } func note(_ message: String) { - append(level: "info", message) + log.note(message) } func updateSnapshot(_ snapshot: SyncEngine.DiagnosticSnapshot) { @@ -259,11 +284,4 @@ final class SyncMonitor { return nil } } - - private func append(level: String, _ message: String) { - entries.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message)) - if entries.count > maxEntries { - entries.removeFirst(entries.count - maxEntries) - } - } } diff --git a/Crossmate/Services/NYTPuzzleUpgrader.swift b/Crossmate/Services/NYTPuzzleUpgrader.swift @@ -87,11 +87,12 @@ enum NYTPuzzleUpgrader { /// is re-attempted next time the game is opened (covers transient /// network / auth failures). @MainActor + @discardableResult static func apply( plan: Plan, store: GameStore, fetch: PuzzleFetch - ) async { + ) async -> Outcome { let outcome = await upgrade( date: plan.date, currentSource: plan.currentSource, @@ -105,6 +106,7 @@ enum NYTPuzzleUpgrader { case .failed: break } + return outcome } /// Two puzzles are structurally equivalent when their grids have the same diff --git a/Crossmate/Views/DiagnosticsView.swift b/Crossmate/Views/DiagnosticsView.swift @@ -36,6 +36,7 @@ private enum TimestampFormatter { struct DiagnosticsView: View { @Environment(\.syncEngine) private var syncEngine @Environment(SyncMonitor.self) private var syncMonitor + @Environment(EventLog.self) private var eventLog @State private var isSyncing = false @@ -84,11 +85,11 @@ struct DiagnosticsView: View { } Section("Recent Events") { - if syncMonitor.entries.isEmpty { + if eventLog.entries.isEmpty { Text("No events captured yet.") .foregroundStyle(.secondary) } else { - ForEach(syncMonitor.entries.reversed()) { entry in + ForEach(eventLog.entries.reversed()) { entry in VStack(alignment: .leading, spacing: 4) { Text( "\(TimestampFormatter.string(from: entry.timestamp, in: .local)) [\(entry.level.uppercased())]" @@ -105,7 +106,7 @@ struct DiagnosticsView: View { } } } - .navigationTitle("iCloud Diagnostics") + .navigationTitle("Diagnostics") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -200,10 +201,10 @@ struct DiagnosticsView: View { lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")") lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")") lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")") - lines.append("Recent Event Count: \(syncMonitor.entries.count)") + lines.append("Recent Event Count: \(eventLog.entries.count)") lines.append("") lines.append("Recent Events (UTC):") - for entry in syncMonitor.entries { + for entry in eventLog.entries { lines.append( "\(TimestampFormatter.string(from: entry.timestamp, in: .utc)) [\(entry.level.uppercased())] \(entry.message)" ) @@ -214,9 +215,10 @@ struct DiagnosticsView: View { struct ShareDiagnosticsView: View { @Environment(SyncMonitor.self) private var syncMonitor + @Environment(EventLog.self) private var eventLog - private var shareEntries: [SyncDiagnosticEntry] { - syncMonitor.entries.filter { + private var shareEntries: [EventLogEntry] { + eventLog.entries.filter { $0.message.localizedCaseInsensitiveContains("share") } }