crossmate

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

commit 8c2630b5a4fabef764622f2fff078df86452fb2d
parent f15ade50015425e709e6370319f450331fbaf0ea
Author: Michael Camilleri <[email protected]>
Date:   Thu, 23 Jul 2026 12:46:43 +0900

Page completed games from CloudKit metadata

The Game List previously had to retain and materialise the user's whole
completed history, making the compact Chronicle representation less
useful for reducing local work and storage.

This commit initially loads only the last seven days, then makes 'Load
More' advance to the next available seven completed games regardless of
date gaps.  It merges lightweight Chronicle metadata with completed Game
roots from the private and shared databases, deduplicates them by
original game, and fetches full payloads only for the selected page.

Chronicle becomes the canonical visible representation whenever both
records exist. The live Game remains hidden locally until
acknowledgement and zone retirement finish. CKSyncEngine excludes
Chronicle assets from eager fetching and uses archive-zone changes to
refresh the bounded Game List window.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 6++++++
MCrossmate/Services/AppServices.swift | 14++++++++++++++
MCrossmate/Sync/Archive.swift | 11++++++++++-
MCrossmate/Sync/CloudQuery.swift | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/GameArchiver.swift | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordApplier.swift | 26++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 38++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameList/GameListView.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
MTests/Unit/ArchiveTests.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcloudkit.ckdb | 3++-
10 files changed, 556 insertions(+), 23 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -614,6 +614,12 @@ struct RootView: View { authorIdentity: services.identity, onRefresh: { await services.refreshLibrary() }, onAppear: { await services.gameListAppeared() }, + onLoadRecentCompleted: { cutoff in + await services.loadRecentCompleted(since: cutoff) + }, + onLoadMoreCompleted: { + await services.loadMoreCompleted() + }, onDisappear: { services.gameListDisappeared() }, onAcceptInvite: { shareURL, pingRecordName, shape in try await acceptInviteFromGameList( diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1425,6 +1425,20 @@ final class AppServices { isGameListVisible = false } + func loadRecentCompleted(since cutoff: Date) async -> GameArchiver.CompletedPage { + guard await ensureICloudSyncStarted() else { + return .init(oldestCompletedAt: nil, hasMore: false) + } + return await gameArchiver.loadRecentCompleted(since: cutoff) + } + + func loadMoreCompleted() async -> GameArchiver.CompletedPage { + guard await ensureICloudSyncStarted() else { + return .init(oldestCompletedAt: nil, hasMore: false) + } + return await gameArchiver.loadMoreCompleted() + } + /// Runs `work` to completion under a `UIApplication` background-execution /// assertion, so a flush or enqueue that begins as the app heads to the /// background still reaches durable state before iOS suspends us. The diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -503,6 +503,7 @@ enum Archive { ) let encoded = try JSONEncoder().encode(blob) let payload = try asset(for: encodeEnvelope(encoded), ext: "cmarchive") + record["completedAt"] = snapshot.completedAt record[payloadKey] = payload.asset return RecordPackage( record: record, @@ -594,8 +595,15 @@ enum Archive { guard blob.formatVersion == 1 else { throw PayloadError.unsupportedFormat(blob.formatVersion) } + guard let recordCompletedAt = record["completedAt"] as? Date else { + throw PayloadError.identityMismatch + } + let completionMetadataMatches = abs( + blob.completedAt.timeIntervalSince(recordCompletedAt) + ) < 0.001 guard blob.originalGameID == recordOriginalID, - blob.archiveGameID == archiveGameID(for: recordOriginalID) + blob.archiveGameID == archiveGameID(for: recordOriginalID), + completionMetadataMatches else { throw PayloadError.identityMismatch } let sourceBytes = blob.puzzleSource.utf8.count guard sourceBytes <= XD.maxSourceBytes else { @@ -740,6 +748,7 @@ enum Archive { entity.updatedAt = payload.completedAt entity.archivedAt = payload.completedAt entity.archiveGameID = archiveID + entity.isHidden = false // A deadline fallback deliberately carries no journal. Mark it // unavailable rather than presenting an authoritative-looking empty or // partial replay. diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -824,6 +824,70 @@ extension SyncEngine { return true } + /// Hydrates a completed game selected by the Game List's metadata pager. + /// Unlike `fetchGameDirect`, this accepts the server zone identity because + /// the game may not have a local Core Data row yet. + @discardableResult + func fetchCompletedGameDirect( + gameID: UUID, + zoneID: CKRecordZone.ID, + scope: DatabaseScope + ) async throws -> Bool { + let database = scope == .private + ? container.privateCloudDatabase + : container.sharedCloudDatabase + let gameRecordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: zoneID + ) + + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: RecordSerializer.gameDesiredKeys + ) + async let movesTask = queryLiveRecords( + type: "Moves", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: RecordSerializer.movesDesiredKeys + ) + async let playersTask = queryLiveRecords( + type: "Player", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: RecordSerializer.playerDesiredKeys + ) + let (gameResults, moves, players) = try await ( + gameResultsTask, + movesTask, + playersTask + ) + guard case .success(let game)? = gameResults[gameRecordID] else { + return false + } + + let records = moves + players + [game] + if let latestModification = records.compactMap(\.modificationDate).max() { + setLiveQueryCheckpoint( + latestModification, + scopeValue: scope, + gameID: gameID + ) + } + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: scope + ) + await trace( + "\(scope == .private ? "private" : "shared") completed-game load: " + + "\(gameID.uuidString.prefix(8)), moves=\(moves.count), players=\(players.count)" + ) + return true + } + nonisolated func isZoneNotFoundError(_ error: Error) -> Bool { let nsError = error as NSError return nsError.domain == CKErrorDomain && diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -8,6 +8,32 @@ import Foundation @MainActor final class GameArchiver { nonisolated static let archiveRetryWindow: TimeInterval = 14 * 24 * 60 * 60 + nonisolated static let completedPageSize = 7 + + struct CompletedPage: Sendable { + let oldestCompletedAt: Date? + let hasMore: Bool + } + + private enum CompletedSource { + case chronicle(CKRecord.ID) + case game( + gameID: UUID, + zoneID: CKRecordZone.ID, + scope: DatabaseScope + ) + } + + private struct CompletedMetadata { + let originalGameID: UUID + let completedAt: Date + let source: CompletedSource + + var isLiveGame: Bool { + if case .game = source { return true } + return false + } + } private struct LocalGame { let snapshot: Archive.Snapshot @@ -34,6 +60,8 @@ final class GameArchiver { private let localDefaults: UserDefaults private let ubiquitousStore: NSUbiquitousKeyValueStore? private var ensuredArchiveZone = false + private var chronicleCursor: CKQueryOperation.Cursor? + private var bufferedCompleted: [CompletedMetadata] = [] init( container: CKContainer, @@ -369,6 +397,257 @@ final class GameArchiver { // MARK: - CloudKit archive I/O + /// Starts a fresh descending scan across Chronicle and completed Game + /// records. Only scalar metadata is read while locating the initial + /// seven-day window; full zones/assets are fetched for visible records. + func loadRecentCompleted(since cutoff: Date) async -> CompletedPage { + chronicleCursor = nil + bufferedCompleted = [] + + var selectedChronicles: [CompletedMetadata] = [] + do { + repeat { + let page = try await fetchChronicleMetadataPage() + chronicleCursor = page.cursor + if let firstOlder = page.records.firstIndex(where: { + $0.completedAt < cutoff + }) { + selectedChronicles.append(contentsOf: page.records[..<firstOlder]) + bufferedCompleted.append(contentsOf: page.records[firstOlder...]) + break + } + selectedChronicles.append(contentsOf: page.records) + } while chronicleCursor != nil + + let gameMetadata = try await fetchCompletedGameMetadata() + let recentGames = gameMetadata.filter { $0.completedAt >= cutoff } + bufferedCompleted.append( + contentsOf: gameMetadata.filter { $0.completedAt < cutoff } + ) + bufferedCompleted = mergedMetadata(bufferedCompleted) + + let selected = mergedMetadata(selectedChronicles + recentGames) + await hydrateCompleted(selected) + await trimMaterializedChronicles(before: cutoff) + return CompletedPage( + oldestCompletedAt: selected.last?.completedAt, + hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil + ) + } catch { + syncMonitor?.recordError("load recent completed games", error) + return CompletedPage( + oldestCompletedAt: nil, + hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil + ) + } + } + + /// Continues the metadata scan and hydrates the next available records, + /// crossing arbitrarily large date gaps in one request. + func loadMoreCompleted() async -> CompletedPage { + do { + while uniqueGameCount(in: bufferedCompleted) < Self.completedPageSize, + let cursor = chronicleCursor { + let page = try await fetchChronicleMetadataPage( + continuing: cursor + ) + chronicleCursor = page.cursor + bufferedCompleted.append(contentsOf: page.records) + bufferedCompleted = mergedMetadata(bufferedCompleted) + } + + let selected = Array(bufferedCompleted.prefix(Self.completedPageSize)) + let selectedIDs = Set(selected.map(\.originalGameID)) + bufferedCompleted.removeAll { + selectedIDs.contains($0.originalGameID) + } + await hydrateCompleted(selected) + return CompletedPage( + oldestCompletedAt: selected.last?.completedAt, + hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil + ) + } catch { + syncMonitor?.recordError("load more completed games", error) + return CompletedPage( + oldestCompletedAt: nil, + hasMore: !bufferedCompleted.isEmpty || chronicleCursor != nil + ) + } + } + + private func fetchChronicleMetadataPage( + continuing cursor: CKQueryOperation.Cursor? = nil + ) async throws -> (records: [CompletedMetadata], cursor: CKQueryOperation.Cursor?) { + let database = container.privateCloudDatabase + let result: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?) + if let cursor { + result = try await database.records( + continuingMatchFrom: cursor, + desiredKeys: ["completedAt"], + resultsLimit: 50 + ) + } else { + let query = CKQuery(recordType: Archive.recordType, predicate: NSPredicate(value: true)) + query.sortDescriptors = [NSSortDescriptor(key: "completedAt", ascending: false)] + result = try await database.records( + matching: query, + inZoneWith: Archive.zoneID, + desiredKeys: ["completedAt"], + resultsLimit: 50 + ) + } + let records = result.matchResults.compactMap { _, item -> CompletedMetadata? in + guard let record = try? item.get(), + let originalGameID = Archive.originalGameID( + fromName: record.recordID.recordName + ), + let completedAt = record["completedAt"] as? Date + else { return nil } + return CompletedMetadata( + originalGameID: originalGameID, + completedAt: completedAt, + source: .chronicle(record.recordID) + ) + } + return (records, result.queryCursor) + } + + private func fetchCompletedGameMetadata() async throws -> [CompletedMetadata] { + async let privateMetadata = fetchCompletedGameMetadata( + database: container.privateCloudDatabase, + scope: .private + ) + async let sharedMetadata = fetchCompletedGameMetadata( + database: container.sharedCloudDatabase, + scope: .shared + ) + return try await privateMetadata + sharedMetadata + } + + private func fetchCompletedGameMetadata( + database: CKDatabase, + scope: DatabaseScope + ) async throws -> [CompletedMetadata] { + let zoneIDs = try await database.allRecordZones() + .map(\.zoneID) + .filter { RecordSerializer.gameID(fromGameRecordName: $0.zoneName) != nil } + let recordIDs = zoneIDs.map { + CKRecord.ID(recordName: $0.zoneName, zoneID: $0) + } + + var metadata: [CompletedMetadata] = [] + for start in stride(from: 0, to: recordIDs.count, by: 200) { + let end = min(start + 200, recordIDs.count) + let batch = Array(recordIDs[start..<end]) + let results = try await database.records( + for: batch, + desiredKeys: ["completedAt"] + ) + for (recordID, result) in results { + guard let record = try? result.get(), + let gameID = RecordSerializer.gameID( + fromGameRecordName: recordID.recordName + ), + let completedAt = record["completedAt"] as? Date + else { continue } + metadata.append(CompletedMetadata( + originalGameID: gameID, + completedAt: completedAt, + source: .game( + gameID: gameID, + zoneID: recordID.zoneID, + scope: scope + ) + )) + } + } + return metadata + } + + /// Collapses a live Game and Chronicle for the same original puzzle into + /// one candidate. Chronicle is the canonical completed representation; + /// the live row remains only for acknowledgement and zone retirement. + private func mergedMetadata( + _ metadata: [CompletedMetadata] + ) -> [CompletedMetadata] { + var byGameID: [UUID: CompletedMetadata] = [:] + for item in metadata { + if let existing = byGameID[item.originalGameID] { + if !item.isLiveGame && existing.isLiveGame { + byGameID[item.originalGameID] = item + } + } else { + byGameID[item.originalGameID] = item + } + } + return byGameID.values.sorted { lhs, rhs in + if lhs.completedAt != rhs.completedAt { + return lhs.completedAt > rhs.completedAt + } + return lhs.originalGameID.uuidString < rhs.originalGameID.uuidString + } + } + + private func uniqueGameCount(in metadata: [CompletedMetadata]) -> Int { + Set(metadata.map(\.originalGameID)).count + } + + private func hydrateCompleted(_ metadata: [CompletedMetadata]) async { + let chronicles = metadata.compactMap { item -> CKRecord.ID? in + if case .chronicle(let recordID) = item.source { return recordID } + return nil + } + await materializeChronicles(chronicles) + + for item in metadata { + guard case .game(let gameID, let zoneID, let scope) = item.source + else { continue } + do { + _ = try await syncEngine.fetchCompletedGameDirect( + gameID: gameID, + zoneID: zoneID, + scope: scope + ) + } catch { + syncMonitor?.recordError("load completed game", error) + } + } + } + + private func materializeChronicles(_ recordIDs: [CKRecord.ID]) async { + guard !recordIDs.isEmpty else { return } + let database = container.privateCloudDatabase + let result = try? await database.records( + for: recordIDs, + desiredKeys: ["completedAt", Archive.payloadKey] + ) + guard let result else { return } + let records = result.compactMap { _, item in try? item.get() } + let ctx = persistence.container.newBackgroundContext() + await ctx.perform { + for record in records { + _ = self.syncEngine.applyPreferredArchiveRecord(record, in: ctx) + } + if ctx.hasChanges { try? ctx.save() } + } + } + + /// Evicts only rows materialized from Chronicle. Live completed games stay + /// local until their archive/acknowledgement/retirement work has converged. + private func trimMaterializedChronicles(before cutoff: Date) async { + let ctx = persistence.container.newBackgroundContext() + await ctx.perform { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "completedAt < %@ AND ckRecordName BEGINSWITH %@", + cutoff as NSDate, + "chronicle-" + ) + for game in (try? ctx.fetch(req)) ?? [] { ctx.delete(game) } + if ctx.hasChanges { try? ctx.save() } + } + } + private func fetchArchive(originalGameID: UUID) async -> StoredArchive? { let name = Archive.recordName(forOriginalGameID: originalGameID) let commonID = CKRecord.ID(recordName: name, zoneID: Archive.zoneID) diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -656,6 +656,32 @@ extension SyncEngine { return created?.id } + /// Applies a Chronicle explicitly selected by the completed-game pager. + /// The compact record becomes the visible representation, while an + /// existing live row is retained invisibly for acknowledgement and zone + /// retirement bookkeeping. + @discardableResult + nonisolated func applyPreferredArchiveRecord( + _ record: CKRecord, + in ctx: NSManagedObjectContext, + onDiagnostic: ((String) -> Void)? = nil + ) -> UUID? { + guard let payload = Archive.payload(from: record, onDiagnostic: onDiagnostic), + let materialized = Archive.materialize(payload, in: ctx) + else { return nil } + + let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + liveReq.predicate = NSPredicate( + format: "id == %@ AND isAccessRevoked == NO", + payload.originalGameID as CVarArg + ) + liveReq.fetchLimit = 1 + if let live = try? ctx.fetch(liveReq).first { + live.isHidden = true + } + return materialized.id + } + /// Callers gate game-scoped deletions through /// `RecordSerializer.isTrustedGameScopedDeletion` before invoking this. nonisolated func applyDeletion( diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -41,6 +41,10 @@ extension Notification.Name { /// replay observes this to re-check completeness the moment a contributor's /// journal syncs, instead of polling. static let replayJournalDidSync = Notification.Name("replayJournalDidSync") + + /// The private Chronicle zone changed. Its assets are deliberately excluded + /// from CKSyncEngine and the Game List performs a bounded metadata query. + static let chronicleZoneDidChange = Notification.Name("chronicleZoneDidChange") } @@ -1592,6 +1596,15 @@ actor SyncEngine { ) await noteRoundTripSuccess() + if isPrivate, + event.modifications.contains(where: { + $0.zoneID.zoneName == Archive.zoneName + }) { + await MainActor.run { + NotificationCenter.default.post(name: .chronicleZoneDidChange, object: nil) + } + } + // Private-DB zone deletions reflect the user removing one of their own // games on another device — hard-delete locally so the row stops // hanging around forever. Shared-DB zone deletions reflect the owner @@ -2638,6 +2651,31 @@ actor SyncEngine { // MARK: - CKSyncEngineDelegate extension SyncEngine: CKSyncEngineDelegate { + func nextFetchChangesOptions( + _ context: CKSyncEngine.FetchChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.FetchChangesOptions { + var options = context.options + guard syncEngine === privateEngine else { return options } + + switch options.scope { + case .all: + options.scope = .allExcluding([Archive.zoneID]) + case .allExcluding(var zoneIDs): + if !zoneIDs.contains(where: { $0.zoneName == Archive.zoneName }) { + zoneIDs.append(Archive.zoneID) + } + options.scope = .allExcluding(zoneIDs) + case .zoneIDs(let zoneIDs): + options.scope = .zoneIDs(zoneIDs.filter { + $0.zoneName != Archive.zoneName + }) + @unknown default: + options.scope = .allExcluding([Archive.zoneID]) + } + return options + } + func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { let isPrivate = syncEngine === privateEngine switch event { diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -7,6 +7,8 @@ struct GameListView: View { let authorIdentity: AuthorIdentity let onRefresh: () async -> Void let onAppear: () async -> Void + let onLoadRecentCompleted: (Date) async -> GameArchiver.CompletedPage + let onLoadMoreCompleted: () async -> GameArchiver.CompletedPage let onDisappear: () -> Void let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)? @Binding var pendingInviteNotificationGameID: UUID? @@ -60,7 +62,13 @@ struct GameListView: View { @State private var showingNamePrompt = false @State private var nameDraft = "" @State private var summaryCache = GameSummaryCache() - @State private var completedVisibleCount = completedPageSize + @State private var completedCutoff = Calendar.current.date( + byAdding: .day, + value: -7, + to: Date() + ) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60) + @State private var cloudHasMoreCompleted = false + @State private var isLoadingMoreCompleted = false /// Shows the "Never show me tips" opt-out in the banner slot for a few /// seconds after a tip is dismissed; cleared by the timer or by tapping it. @State private var showTipOptOut = false @@ -68,8 +76,6 @@ struct GameListView: View { @State private var containerHeight: CGFloat = 0 @AccessibilityFocusState private var isPuzzleListHeadingFocused: Bool - private static let completedPageSize = 7 - private struct NewGamePresentation: Identifiable { let id = UUID() let inviteTarget: FriendNewGameTarget? @@ -162,9 +168,17 @@ struct GameListView: View { } .task { await onAppear() + let page = await onLoadRecentCompleted(completedCutoff) + cloudHasMoreCompleted = page.hasMore reconcilePendingInviteNotification() focusPuzzleListHeading() } + .onReceive(NotificationCenter.default.publisher(for: .chronicleZoneDidChange)) { _ in + Task { + let page = await onLoadRecentCompleted(completedCutoff) + cloudHasMoreCompleted = page.hasMore + } + } #if DEBUG .task { // Marketing "import" scene: render the real New Puzzle sheet over the @@ -386,9 +400,17 @@ struct GameListView: View { let completed = summaries .filter { $0.completedAt != nil && !$0.isAccessRevoked } .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } - let visibleCompletedCount = min(completedVisibleCount, completed.count) - let visibleCompleted = Array(completed.prefix(visibleCompletedCount)) - let hasMore = visibleCompletedCount < completed.count + let visibleCompleted = completed.filter { + ($0.completedAt ?? .distantPast) >= completedCutoff + } + let olderLocal = completed.filter { + ($0.completedAt ?? .distantPast) < completedCutoff + } + let nextLocalCutoff = olderLocal + .prefix(GameArchiver.completedPageSize) + .last? + .completedAt + let hasMore = cloudHasMoreCompleted || !olderLocal.isEmpty let blockedIDs = Set(blockedFriends.compactMap { $0.authorID }) let visibleInvites = pendingInvites.filter { @@ -404,6 +426,7 @@ struct GameListView: View { revoked: revoked, completed: visibleCompleted, hasMore: hasMore, + nextLocalCutoff: nextLocalCutoff, usesRoomierType: usesRoomierType ) } else { @@ -413,6 +436,7 @@ struct GameListView: View { revoked: revoked, completed: visibleCompleted, hasMore: hasMore, + nextLocalCutoff: nextLocalCutoff, usesRoomierType: usesRoomierType ) } @@ -444,11 +468,6 @@ struct GameListView: View { .background(Color(.systemGroupedBackground)) } } - .onChange(of: completed.count) { oldCount, newCount in - if newCount > oldCount { - completedVisibleCount += newCount - oldCount - } - } } // MARK: - List layout (compact width / iPhone) @@ -460,6 +479,7 @@ struct GameListView: View { revoked: [GameSummary], completed: [GameSummary], hasMore: Bool, + nextLocalCutoff: Date?, usesRoomierType: Bool ) -> some View { List { @@ -493,7 +513,7 @@ struct GameListView: View { } } - if !completed.isEmpty { + if !completed.isEmpty || hasMore { Section { ForEach(completed) { game in rowView(for: game, usesRoomierType: usesRoomierType) @@ -502,13 +522,13 @@ struct GameListView: View { listSectionHeader("Completed") } footer: { if hasMore { - loadMoreButton + loadMoreButton(nextLocalCutoff: nextLocalCutoff) } } } } .refreshable { - await onRefresh() + await refreshList() } } @@ -533,6 +553,7 @@ struct GameListView: View { revoked: [GameSummary], completed: [GameSummary], hasMore: Bool, + nextLocalCutoff: Date?, usesRoomierType: Bool ) -> some View { ScrollView { @@ -576,7 +597,7 @@ struct GameListView: View { } } - if !completed.isEmpty { + if !completed.isEmpty || hasMore { Section { LazyVGrid(columns: gridColumns, spacing: 12) { ForEach(completed) { game in @@ -586,7 +607,7 @@ struct GameListView: View { .padding(.horizontal) if hasMore { - loadMoreButton + loadMoreButton(nextLocalCutoff: nextLocalCutoff) .padding(.horizontal) } } header: { @@ -598,10 +619,16 @@ struct GameListView: View { } .background(Color(.systemGroupedBackground)) .refreshable { - await onRefresh() + await refreshList() } } + private func refreshList() async { + await onRefresh() + let page = await onLoadRecentCompleted(completedCutoff) + cloudHasMoreCompleted = page.hasMore + } + private func gridSectionHeader(_ title: String) -> some View { Text(title) .font(.footnote.weight(.semibold)) @@ -613,21 +640,38 @@ struct GameListView: View { .accessibilityAddTraits(.isHeader) } - private var loadMoreButton: some View { + private func loadMoreButton(nextLocalCutoff: Date?) -> some View { HStack { Spacer() Button { - withAnimation(.easeInOut(duration: 0.25)) { - completedVisibleCount += Self.completedPageSize + Task { + isLoadingMoreCompleted = true + let page = await onLoadMoreCompleted() + let cutoffs = [nextLocalCutoff, page.oldestCompletedAt].compactMap { $0 } + if let oldest = cutoffs.min() { + withAnimation(.easeInOut(duration: 0.25)) { + completedCutoff = oldest + } + } + cloudHasMoreCompleted = page.hasMore + isLoadingMoreCompleted = false } } label: { - Text("Load More") + if isLoadingMoreCompleted { + ProgressView() + .controlSize(.small) + .padding(.horizontal, 18) + .padding(.vertical, 8) + } else { + Text("Load More") .font(.subheadline.weight(.semibold)) .foregroundColor(.secondary) .padding(.horizontal, 18) .padding(.vertical, 8) .background(Color(.tertiarySystemFill), in: Capsule()) + } } + .disabled(isLoadingMoreCompleted) .buttonStyle(.plain) .textCase(nil) Spacer() diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -94,6 +94,24 @@ struct ArchiveTests { Dictionary(uniqueKeysWithValues: journals.map { ($0.key, $0.entries) }) } + @Test("Chronicle completion metadata matches its compressed payload") + func completionMetadataMatchesPayload() throws { + let snapshot = sampleSnapshot(originalGameID: UUID()) + try withArchiveRecord(from: snapshot) { record in + #expect(record["completedAt"] as? Date == snapshot.completedAt) + #expect(Archive.payload(from: record)?.completedAt == snapshot.completedAt) + } + } + + @Test("Chronicle rejects completion metadata that disagrees with its payload") + func mismatchedCompletionMetadataRejected() throws { + let snapshot = sampleSnapshot(originalGameID: UUID()) + try withArchiveRecord(from: snapshot) { record in + record["completedAt"] = snapshot.completedAt.addingTimeInterval(1) + #expect(Archive.payload(from: record) == nil) + } + } + private func withArchiveRecord<T>( from snapshot: Archive.Snapshot, _ body: (CKRecord) throws -> T @@ -618,6 +636,40 @@ struct ArchiveTests { #expect(try ctx.count(for: req) == 0) } + @Test("preferred Chronicle hides but retains its live Game") + func preferredChronicleReplacesLiveGameInList() throws { + let persistence = makeTestPersistence() + let engine = try makeSyncEngine(persistence) + let ctx = persistence.viewContext + let original = UUID() + + let live = GameEntity(context: ctx) + live.id = original + live.title = "Live" + live.puzzleSource = source + live.createdAt = Date() + live.updatedAt = Date() + live.databaseScope = 1 + live.ckRecordName = "game-\(original.uuidString)" + try ctx.save() + + let result = try withArchiveRecord( + from: sampleSnapshot(originalGameID: original) + ) { record in + engine.applyPreferredArchiveRecord(record, in: ctx) + } + #expect(result == Archive.archiveGameID(for: original)) + #expect(live.isHidden) + + let archiveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + archiveReq.predicate = NSPredicate( + format: "id == %@", + Archive.archiveGameID(for: original) as CVarArg + ) + let archive = try #require(ctx.fetch(archiveReq).first) + #expect(!archive.isHidden) + } + @Test("applier materializes when the original is absent") func applierMaterializesWhenAbsent() throws { let persistence = makeTestPersistence() diff --git a/cloudkit.ckdb b/cloudkit.ckdb @@ -25,9 +25,10 @@ DEFINE SCHEMA "___createTime" TIMESTAMP, "___createdBy" REFERENCE, "___etag" STRING, - "___modTime" TIMESTAMP, + "___modTime" TIMESTAMP QUERYABLE, "___modifiedBy" REFERENCE, "___recordID" REFERENCE, + completedAt TIMESTAMP QUERYABLE SORTABLE, payload ASSET, GRANT WRITE TO "_creator", GRANT CREATE TO "_icloud",