crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Persistence/GameStore.swift | 31++++++++++++++++++++++++-------
MCrossmate/Views/Browse/CalendarDayCell.swift | 41++++++++++++++++++++++++++++++++---------
MCrossmate/Views/Browse/ExternalBrowseView.swift | 4++--
MCrossmate/Views/Browse/NYTBrowseView.swift | 14++++++++++++--
MCrossmate/Views/Browse/NewGameSheet.swift | 8+++++++-
ATests/Unit/GameStoreLibraryDatesTests.swift | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)]) + } +}