commit 9dd09d10cfa319cc25b468c56305a8097cd69040
parent b5a8df35d2fec38d3c20c1c924b325cc3043ced2
Author: Michael Camilleri <[email protected]>
Date: Sun, 2 Aug 2026 10:18:03 +0900
Mark External calendar dates already in the library
Browsing the the external provider calendar gave no indication of which
puzzles the user already had. A repeat surfaced only after a date was
chosen, as the 'Puzzle Already in Library' alert.
This commit fills those days with a grey circle. The library is filtered
to ignore blocked or Chronicle-superseded games. It reads the cached
publication dates as dictionary results rather than materialising
GameEntity rows, which previously drew every stored puzzle's XD text
through the row cache.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
7 files changed, 208 insertions(+), 21 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -253,6 +253,7 @@
F15591B48E4155CB19C1F084 /* TimeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */; };
F1854B64C63447B7C7232097 /* DiagnosticsReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D36E66AE489DC5C5FB85E19 /* DiagnosticsReport.swift */; };
F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */; };
+ F30141060E0F670CD329FF22 /* GameStoreLibraryDatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12EEFC257DA9F0E4C4C6B814 /* GameStoreLibraryDatesTests.swift */; };
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; };
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */; };
@@ -317,6 +318,7 @@
10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteCoordinator.swift; sourceTree = "<group>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreMergedAuthorCellsTests.swift; sourceTree = "<group>"; };
+ 12EEFC257DA9F0E4C4C6B814 /* GameStoreLibraryDatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreLibraryDatesTests.swift; sourceTree = "<group>"; };
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; };
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; };
16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudQuery.swift; sourceTree = "<group>"; };
@@ -649,6 +651,7 @@
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */,
09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */,
+ 12EEFC257DA9F0E4C4C6B814 /* GameStoreLibraryDatesTests.swift */,
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */,
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
DD8E8D1BC7DF9EBB08F8CABD /* GameStoreRevocationLockTests.swift */,
@@ -1129,6 +1132,7 @@
30703DDF575DCDA53227DA66 /* GamePushCredentialsTests.swift in Sources */,
9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */,
4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */,
+ F30141060E0F670CD329FF22 /* GameStoreLibraryDatesTests.swift in Sources */,
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
205894D447BFE2CCEB07575D /* GameStoreRevocationLockTests.swift in Sources */,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1307,14 +1307,31 @@ final class GameStore {
return (try? context.fetch(fallback).first?.id)
}
- /// Returns NYT publication dates already present in the local library.
+ /// 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".
+ ///
+ /// Fetched as dictionaries rather than managed objects: the only column
+ /// wanted is a `Date`, and materialising `GameEntity` rows would drag the
+ /// whole library's `puzzleSource` XD text through the row cache with it.
+ /// Dictionary results read the store rather than the context, which is
+ /// safe here because `createGame` saves before it returns.
func nytPuzzleDatesInLibrary() -> Set<Date> {
- let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- request.predicate = NSPredicate(
- format: "cachedPublisher == %@ AND cachedPuzzleDate != nil",
- "New York Times"
- )
- let dates = ((try? context.fetch(request)) ?? []).compactMap(\.cachedPuzzleDate)
+ let request = NSFetchRequest<NSDictionary>(entityName: "GameEntity")
+ request.resultType = .dictionaryResultType
+ request.propertiesToFetch = ["cachedPuzzleDate"]
+ request.returnsDistinctResults = true
+ request.includesPendingChanges = false
+ request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+ GameEntity.visibleInGameListPredicate,
+ NSPredicate(
+ format: "cachedPublisher == %@ AND cachedPuzzleDate != nil",
+ "New York Times"
+ ),
+ ])
+ let rows = (try? context.fetch(request)) ?? []
+ let dates = rows.compactMap { $0["cachedPuzzleDate"] as? Date }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt
diff --git a/Crossmate/Views/Browse/CalendarDayCell.swift b/Crossmate/Views/Browse/CalendarDayCell.swift
@@ -6,6 +6,7 @@ struct CalendarDayCell: View {
let isEnabled: Bool
let isToday: Bool
let isSelected: Bool
+ let isInLibrary: Bool
let onTap: () -> Void
var body: some View {
@@ -16,14 +17,33 @@ struct CalendarDayCell: View {
.frame(maxWidth: .infinity, minHeight: 44)
.foregroundStyle(foregroundColor)
.background {
- if isSelected {
- Circle()
- .fill(Color.accentColor)
- .frame(width: 36, height: 36)
- } else if isToday {
- Circle()
- .fill(Color.accentColor.opacity(0.15))
- .frame(width: 36, height: 36)
+ ZStack {
+ // A day already in the library owns the fill, so the
+ // grey circle is never displaced by selection state.
+ if isInLibrary {
+ Circle()
+ .fill(Color(.systemFill))
+ .frame(width: 36, height: 36)
+ } else if isSelected {
+ Circle()
+ .fill(Color.accentColor)
+ .frame(width: 36, height: 36)
+ } else if isToday {
+ Circle()
+ .fill(Color.accentColor.opacity(0.15))
+ .frame(width: 36, height: 36)
+ }
+ // With the fill taken, selection moves to a ring drawn
+ // inside the same 36pt footprint — an outset ring would
+ // sit on the cell's edge in the narrowest column.
+ if isInLibrary, isSelected || isToday {
+ Circle()
+ .strokeBorder(
+ isSelected ? Color.accentColor : Color.accentColor.opacity(0.4),
+ lineWidth: 2.5
+ )
+ .frame(width: 36, height: 36)
+ }
}
}
}
@@ -36,7 +56,9 @@ struct CalendarDayCell: View {
}
private var foregroundColor: Color {
- if isSelected { return .white }
+ // White reads against the accent fill, but a selected library day is
+ // filled grey and keeps the normal label colour.
+ if isSelected, !isInLibrary { return .white }
return isEnabled ? .primary : .secondary.opacity(0.4)
}
@@ -48,6 +70,7 @@ struct CalendarDayCell: View {
var parts: [String] = []
if isToday { parts.append("Today") }
if isSelected { parts.append("Selected") }
+ if isInLibrary { parts.append("In your library") }
if !isEnabled { parts.append("Unavailable") }
return parts.joined(separator: ", ")
}
diff --git a/Crossmate/Views/Browse/ExternalBrowseView.swift b/Crossmate/Views/Browse/ExternalBrowseView.swift
@@ -2,7 +2,7 @@ import SwiftUI
struct ExternalBrowseView: View {
let onSelected: (String) -> Void
- let excludedDates: Set<Date>
+ let libraryDates: Set<Date>
@Environment(NYTAuthService.self) private var nytAuth
@State private var provider: ExternalPuzzleProvider = .newYorkTimes
@@ -28,7 +28,7 @@ struct ExternalBrowseView: View {
case .newYorkTimes:
NYTBrowseView(
onSelected: onSelected,
- excludedDates: excludedDates
+ libraryDates: libraryDates
)
}
}
diff --git a/Crossmate/Views/Browse/NYTBrowseView.swift b/Crossmate/Views/Browse/NYTBrowseView.swift
@@ -2,7 +2,9 @@ import SwiftUI
struct NYTBrowseView: View {
let onSelected: (String) -> Void
- var excludedDates: Set<Date> = []
+ /// NYT publication dates already in the local library. Marks those days in
+ /// the grid, and keeps the random picker off puzzles already held.
+ var libraryDates: Set<Date> = []
@Environment(\.nytPuzzleFetcher) private var fetcher
@Environment(NYTAuthService.self) private var nytAuth
@@ -248,6 +250,7 @@ struct NYTBrowseView: View {
isEnabled: isEnabled(date),
isToday: cal.isDateInToday(date),
isSelected: isSelected(date),
+ isInLibrary: isInLibrary(date),
onTap: { selectedDate = date }
)
} else {
@@ -409,7 +412,7 @@ struct NYTBrowseView: View {
let matchesWeekday = randomWeekday.map { cal.component(.weekday, from: day) == $0 } ?? true
if matchesWeekday {
let dayStart = cal.startOfDay(for: day)
- if !excludedDates.contains(dayStart) {
+ if !libraryDates.contains(dayStart) {
candidates.append(dayStart)
}
}
@@ -464,6 +467,13 @@ struct NYTBrowseView: View {
return Self.nytCalendar.isDate(date, inSameDayAs: selectedDate)
}
+ /// Grid cells are already NY midnight, as are the stored publication dates
+ /// the set is built from, but normalise anyway so the lookup can't be
+ /// broken by a legacy row that landed on some other time of day.
+ private func isInLibrary(_ date: Date) -> Bool {
+ libraryDates.contains(Self.nytCalendar.startOfDay(for: date))
+ }
+
// MARK: - Month navigation
private func shiftMonth(by delta: Int) {
diff --git a/Crossmate/Views/Browse/NewGameSheet.swift b/Crossmate/Views/Browse/NewGameSheet.swift
@@ -10,6 +10,9 @@ struct NewGameSheet: View {
@AppStorage("lastPuzzleSource") private var selection: PuzzleSource = .bundles
@State private var duplicateSource: String?
@State private var createError: CreateError?
+ /// Loaded once when the sheet appears rather than read inside `body`,
+ /// which would re-run the fetch on every body evaluation.
+ @State private var libraryDates: Set<Date> = []
private struct CreateError: Identifiable {
let id = UUID()
@@ -37,13 +40,16 @@ struct NewGameSheet: View {
case .external:
ExternalBrowseView(
onSelected: handleSelected,
- excludedDates: store.nytPuzzleDatesInLibrary()
+ libraryDates: libraryDates
)
}
}
}
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ libraryDates = store.nytPuzzleDatesInLibrary()
+ }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
diff --git a/Tests/Unit/GameStoreLibraryDatesTests.swift b/Tests/Unit/GameStoreLibraryDatesTests.swift
@@ -0,0 +1,127 @@
+import CoreData
+import Foundation
+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.
+@Suite("GameStore library dates")
+@MainActor
+struct GameStoreLibraryDatesTests {
+
+ private static func source(publisher: String, date: String) -> String {
+ """
+ Title: Test Puzzle
+ Author: Test
+ Publisher: \(publisher)
+ Date: \(date)
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Across 1 ~ ABC
+ A4. Across 4 ~ DE
+ A5. Across 5 ~ FGH
+ D1. Down 1 ~ ADF
+ D2. Down 2 ~ BG
+ D3. Down 3 ~ CEH
+ """
+ }
+
+ /// The NY-midnight Date that `XD` parses a `YYYY-MM-DD` header into, which
+ /// is also what the calendar grid produces for that day.
+ private static func nytDay(_ year: Int, _ month: Int, _ day: Int) -> Date {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "America/New_York")!
+ var comps = DateComponents()
+ comps.year = year
+ comps.month = month
+ comps.day = day
+ return calendar.date(from: comps)!
+ }
+
+ @discardableResult
+ private func addGame(
+ 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)
+ let puzzle = Puzzle(xd: try XD.parse(source))
+
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = puzzle.title
+ entity.puzzleSource = source
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ 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
+ }
+
+ @Test("Stored NYT dates match the calendar's day values")
+ func returnsStoredNYTDates() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addGame(date: "2020-01-01", in: persistence.viewContext)
+ try addGame(date: "2024-07-04", in: persistence.viewContext)
+
+ let dates = store.nytPuzzleDatesInLibrary()
+
+ #expect(dates == [Self.nytDay(2020, 1, 1), Self.nytDay(2024, 7, 4)])
+ }
+
+ @Test("A game hidden by the block table is not reported as held")
+ func excludesHiddenGames() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addGame(date: "2020-01-01", isHidden: true, in: persistence.viewContext)
+
+ #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()
+ let store = makeTestStore(persistence: persistence)
+ try addGame(publisher: "Crossmate", date: "2020-01-01", in: persistence.viewContext)
+
+ #expect(store.nytPuzzleDatesInLibrary().isEmpty)
+ }
+
+ @Test("The same publication date held twice collapses to one entry")
+ func deduplicatesRepeatedDates() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addGame(date: "2020-01-01", in: persistence.viewContext)
+ try addGame(date: "2020-01-01", in: persistence.viewContext)
+
+ #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2020, 1, 1)])
+ }
+}