commit 27076c6a1ea6bf4ee674a7a513ec26ad71601ca1
parent 7a77a1dc023e9db208e6dd7a22e0ed1a4d939c5e
Author: Michael Camilleri <[email protected]>
Date: Sun, 2 Aug 2026 19:56:36 +0900
Mark completed puzzles on the External calendar
The grey circle marking a day already in the library only appeared for
puzzles still in progress. Once a completed game was compacted into a
Chronicle its day went unmarked, even though choosing that date still
raised the 'Puzzle Already in Library' alert.
Two things hid those days. Archive.materialize never called
populateCachedSummaryFields (so a Chronicle carried no cachedPublisher
or cachedPuzzleDate to match) and the query also excluded rows flagged
isSupersededByChronicle (which dropped the live half of the pair).
This commit populates the derived columns when a Chronicle is
materialised and narrows the query to isHidden. Whether a live game and
its Chronicle have been compacted yet is invisible to the user, so it
should not change what the calendar shows; the returned Set collapses
the publication date the pair shares. Chronicles already on disk are
repaired by a backfill in PersistenceController, self-limiting on
gridWidth rather than a stored flag.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
5 files changed, 68 insertions(+), 28 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1307,10 +1307,13 @@ final class GameStore {
return (try? context.fetch(fallback).first?.id)
}
- /// Returns NYT publication dates already present in the local library,
- /// counting only games the Game List itself would show: a game hidden by
- /// the block table, or a live row already superseded by its Chronicle,
- /// isn't something the browser should report back as "you have this".
+ /// Returns NYT publication dates already present in the local library.
+ /// Games hidden by the block table are excluded — those aren't something
+ /// the browser should report back as "you have this" — but a live row
+ /// superseded by its Chronicle is *not*: the user still holds that puzzle,
+ /// and whether the pair has been compacted yet is invisible to them. Both
+ /// rows in a live/Chronicle family carry the same publication date, and
+ /// the returned `Set` collapses the duplicate.
///
/// Fetched as dictionaries rather than managed objects: the only column
/// wanted is a `Date`, and materialising `GameEntity` rows would drag the
@@ -1323,13 +1326,10 @@ final class GameStore {
request.propertiesToFetch = ["cachedPuzzleDate"]
request.returnsDistinctResults = true
request.includesPendingChanges = false
- request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
- GameEntity.visibleInGameListPredicate,
- NSPredicate(
- format: "cachedPublisher == %@ AND cachedPuzzleDate != nil",
- "New York Times"
- ),
- ])
+ request.predicate = NSPredicate(
+ format: "isHidden == NO AND cachedPublisher == %@ AND cachedPuzzleDate != nil",
+ "New York Times"
+ )
let rows = (try? context.fetch(request)) ?? []
let dates = rows.compactMap { $0["cachedPuzzleDate"] as? Date }
diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift
@@ -68,6 +68,7 @@ final class PersistenceController {
// user can still open the broken row on this launch.
healStrandedSharedGameRows_v1()
backfillZoneIdentityFields()
+ backfillCachedSummaryFields()
}
}
@@ -290,6 +291,46 @@ final class PersistenceController {
// MARK: - Backfill
+ /// Populates the derived summary columns on rows that never got them.
+ ///
+ /// Chronicles materialized before `Archive.materialize` populated these
+ /// are the population that matters: `GameSummary` papers over the gap by
+ /// reparsing `puzzleSource`, but anything that queries the columns
+ /// directly — the browser's "already in your library" lookup — simply
+ /// can't see those rows.
+ ///
+ /// Self-limiting rather than flag-gated: a populated row no longer matches
+ /// `gridWidth == 0`. A row whose source won't parse stays unmatched and is
+ /// retried on later launches, which is the right outcome for the handful
+ /// of rows that could be in that state.
+ private func backfillCachedSummaryFields() {
+ let bg = container.newBackgroundContext()
+ bg.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@",
+ ""
+ )
+ guard let rows = try? bg.fetch(req), !rows.isEmpty else { return }
+ for entity in rows {
+ guard let source = entity.puzzleSource,
+ let xd = try? XD.parse(source) else { continue }
+ entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
+ }
+ guard bg.hasChanges else { return }
+ do {
+ try bg.save()
+ } catch {
+ Task { @MainActor [weak self] in
+ self?.eventLog?.note(
+ "PersistenceController: backfillCachedSummaryFields save failed — \(error)",
+ level: "error"
+ )
+ }
+ }
+ }
+ }
+
/// One-shot pass for `GameEntity` rows written before inbound lookups
/// matched on full zone identity (`RecordSerializer.gameIdentityPredicate`).
/// Without it, a legacy row is invisible to the new predicate and the next
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -905,6 +905,15 @@ enum Archive {
entity.syncVersion = GameSyncVersion.legacy
entity.title = payload.title
entity.puzzleSource = payload.puzzleSource
+ // Chronicles are ordinary library rows to everything that reads the
+ // derived columns — the browser's "already have this" lookup queries
+ // `cachedPublisher`/`cachedPuzzleDate` directly, and `GameSummary`
+ // would otherwise reparse this XD on every list evaluation. The frozen
+ // `puzzleParserVersion` is deliberately left alone: it still gates the
+ // catalog re-derivation in `preparePuzzleForLoad`.
+ if let xd = try? XD.parse(payload.puzzleSource) {
+ entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
+ }
entity.completedAt = payload.completedAt
entity.completedBy = payload.completedBy
// The frozen solve time the live clock reached; `PlayerRoster.solveTime`
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -978,6 +978,10 @@ struct ArchiveTests {
let archive = try #require(ctx.fetch(archiveReq).first)
#expect(!archive.isHidden)
#expect(!archive.isSupersededByChronicle)
+ // Derived columns are populated at materialization, so queries that
+ // read them (rather than reparsing the source) can see Chronicles.
+ #expect(archive.gridWidth == 3)
+ #expect(archive.blockMask?.count == 9)
let liveSummary = try #require(GameSummary(
entity: live,
diff --git a/Tests/Unit/GameStoreLibraryDatesTests.swift b/Tests/Unit/GameStoreLibraryDatesTests.swift
@@ -5,8 +5,9 @@ import Testing
@testable import Crossmate
/// Covers the query behind the External browser's "already in your library"
-/// calendar marker. The set it returns must reflect what the Game List shows,
-/// so a day isn't marked on the strength of a game the user can no longer see.
+/// calendar marker. A day is marked whenever the user holds that puzzle in any
+/// form — live row or Chronicle — and not on the strength of a game the block
+/// table has taken away from them.
@Suite("GameStore library dates")
@MainActor
struct GameStoreLibraryDatesTests {
@@ -50,7 +51,6 @@ struct GameStoreLibraryDatesTests {
publisher: String = "New York Times",
date: String,
isHidden: Bool = false,
- isSupersededByChronicle: Bool = false,
in ctx: NSManagedObjectContext
) throws -> GameEntity {
let source = Self.source(publisher: publisher, date: date)
@@ -66,7 +66,6 @@ struct GameStoreLibraryDatesTests {
entity.ckRecordName = "game-\(gameID.uuidString)"
entity.ckZoneName = "game-\(gameID.uuidString)"
entity.isHidden = isHidden
- entity.isSupersededByChronicle = isSupersededByChronicle
entity.populateCachedSummaryFields(from: puzzle)
try ctx.save()
return entity
@@ -93,19 +92,6 @@ struct GameStoreLibraryDatesTests {
#expect(store.nytPuzzleDatesInLibrary().isEmpty)
}
- @Test("A live row superseded by its Chronicle is not double-counted")
- func excludesSupersededGames() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- try addGame(
- date: "2020-01-01",
- isSupersededByChronicle: true,
- in: persistence.viewContext
- )
-
- #expect(store.nytPuzzleDatesInLibrary().isEmpty)
- }
-
@Test("Puzzles from other publishers are excluded")
func excludesOtherPublishers() throws {
let persistence = makeTestPersistence()