crossmate

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

commit 9448e53ad968310e2e981260647760c279723f33
parent 16bd2fc90e1639aa5c70d6311ee7438f7702aff3
Author: Michael Camilleri <[email protected]>
Date:   Fri, 24 Apr 2026 06:53:26 +0900

Ensure that list previews are up to date

After the recent changes to the data flow, list previews in Crossmate
were not updating when the user returned from the puzzle view to the
list view. This commit fixes that.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 44++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 3+++
MCrossmate/Sync/MoveBuffer.swift | 8+++++++-
3 files changed, 54 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -107,6 +107,50 @@ final class GameStore { restore(game: game, from: entity) } + /// Replays the move log for each game ID and updates the `CellEntity` + /// cache so that list thumbnails reflect local edits immediately after a + /// `MoveBuffer` flush, without waiting for the next sync cycle. + func replayCellCaches(for gameIDs: Set<UUID>) { + for gameID in gameIDs { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? context.fetch(req).first else { continue } + + let snapReq = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + snapReq.predicate = NSPredicate(format: "game == %@", entity) + let snapshots: [Snapshot] = ((try? context.fetch(snapReq)) ?? []).compactMap { se in + guard let data = se.gridState, + let grid = try? MoveLog.decodeGridState(data) else { return nil } + return Snapshot( + gameID: gameID, + upToLamport: se.upToLamport, + grid: grid, + createdAt: se.createdAt ?? Date() + ) + } + + let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") + moveReq.predicate = NSPredicate(format: "game == %@", entity) + let moves: [Move] = ((try? context.fetch(moveReq)) ?? []).map { me in + Move( + gameID: gameID, + lamport: me.lamport, + row: Int(me.row), + col: Int(me.col), + letter: me.letter ?? "", + markKind: me.markKind, + checkedWrong: me.checkedWrong, + authorID: me.authorID, + createdAt: me.createdAt ?? Date() + ) + } + + let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) + updateCellCache(for: entity, from: grid) + } + } + // MARK: - Load a specific game /// Loads a game by its entity ID. Sets it as the current game. diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -32,6 +32,9 @@ final class AppServices { persistence: persistence, sink: { moves in await syncEngine.enqueueMoves(moves) + }, + afterFlush: { gameIDs in + await store.replayCellCaches(for: gameIDs) } ) self.moveBuffer = moveBuffer diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift @@ -30,6 +30,7 @@ actor MoveBuffer { private let debounceInterval: Duration private let persistence: PersistenceController private let sink: @Sendable ([Move]) async -> Void + private let afterFlush: (@Sendable (Set<UUID>) async -> Void)? private var buffer: [Key: Pending] = [:] /// Insertion order so that lamports within a single flush are assigned @@ -45,11 +46,13 @@ actor MoveBuffer { init( debounceInterval: Duration = .milliseconds(1500), persistence: PersistenceController, - sink: @escaping @Sendable ([Move]) async -> Void + sink: @escaping @Sendable ([Move]) async -> Void, + afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil ) { self.debounceInterval = debounceInterval self.persistence = persistence self.sink = sink + self.afterFlush = afterFlush } /// Registers a cell edit. If the edit targets a different cell than the @@ -119,6 +122,9 @@ actor MoveBuffer { let moves = persistAndAssignLamports(snapshot: snapshot, order: snapshotOrder) guard !moves.isEmpty else { return } await sink(moves) + if let afterFlush { + await afterFlush(Set(moves.map { $0.gameID })) + } } /// Allocates lamports from each game's `lamportHighWater`, writes