crossmate

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

commit ce46e14884c49bdd5df58fd972b2199db2371ec8
parent 8ca71fefae01e3d7676c29cb16e2c0eebb5fa80a
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 18:29:33 +0900

Refine NYT random puzzle selection

This commit makes the NYT random puzzle button avoid dates already in
the local library before it fetches a puzzle. The picker now receives the
set of New York Times publication dates cached on existing games and
chooses from the remaining dates that match the selected weekday and
year.

The random button also switches the selected weekday label to localized
short names and reserves enough label width for those options. This keeps
Liquid Glass from re-laying out the control when the selected weekday
changes, while removing the underline treatment that produced its own
visual artifacts.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 14++++++++++++++
MCrossmate/Views/NYTBrowseView.swift | 59++++++++++++++++++++++++++++++++++++++---------------------
MCrossmate/Views/NewGameSheet.swift | 5++++-
3 files changed, 56 insertions(+), 22 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -592,6 +592,20 @@ final class GameStore { return (try? context.fetch(fallback).first?.id) } + /// Returns NYT publication dates already present in the local library. + 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) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt + return Set(dates.map { calendar.startOfDay(for: $0) }) + } + /// Returns joined CloudKit-share games that have a usable puzzle payload. /// Placeholders created from shared-zone discovery are intentionally /// excluded until the root Game record has arrived. diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift @@ -2,6 +2,7 @@ import SwiftUI struct NYTBrowseView: View { let onSelected: (String) -> Void + var excludedDates: Set<Date> = [] @Environment(\.nytPuzzleFetcher) private var fetcher @Environment(NYTAuthService.self) private var nytAuth @@ -69,6 +70,17 @@ struct NYTBrowseView: View { fullWeekdaySymbols[(weekday - 1) % fullWeekdaySymbols.count] } + private static let shortWeekdaySymbols: [String] = { + let formatter = DateFormatter() + formatter.locale = .current + return formatter.shortWeekdaySymbols + }() + + private static func shortWeekdayName(_ weekday: Int) -> String { + // weekday uses Gregorian numbering: 1 = Sunday … 7 = Saturday + shortWeekdaySymbols[(weekday - 1) % shortWeekdaySymbols.count] + } + // Gregorian weekday numbers. The menu opens upward from the bottom-anchored // button, so the first declared item lands nearest the button (visually at // the bottom). Declaring Sunday → Monday here yields a top-to-bottom reading @@ -250,7 +262,10 @@ struct NYTBrowseView: View { Button(Self.weekdayName(weekday)) { randomWeekday = weekday } } } label: { - menuLabel(randomWeekday.map(Self.weekdayName) ?? "day") + menuLabel( + randomWeekday.map(Self.shortWeekdayName) ?? "day", + reservingWidthFor: Self.weekdayMenuLabelOptions + ) } } @@ -264,15 +279,19 @@ struct NYTBrowseView: View { } } - private func menuLabel(_ text: String) -> some View { + private static var weekdayMenuLabelOptions: [String] { + ["day"] + weekdayMenuOrder.map(shortWeekdayName) + } + + private func menuLabel(_ text: String, reservingWidthFor options: [String] = []) -> some View { HStack(spacing: 2) { - Text(text) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color.accentColor) - .frame(height: 1) - .offset(y: 4) + ZStack { + ForEach(options, id: \.self) { option in + Text(option) + .hidden() } + Text(text) + } Image(systemName: "chevron.down") .font(.caption2.weight(.bold)) .foregroundStyle(Color.accentColor) @@ -302,22 +321,20 @@ struct NYTBrowseView: View { let upper = min(cal.startOfDay(for: yearEnd), todayStart) guard lower <= upper else { return nil } - if let weekday = randomWeekday { - var candidates: [Date] = [] - var day = lower - while day <= upper { - if cal.component(.weekday, from: day) == weekday { - candidates.append(day) + var candidates: [Date] = [] + var day = lower + while day <= upper { + let matchesWeekday = randomWeekday.map { cal.component(.weekday, from: day) == $0 } ?? true + if matchesWeekday { + let dayStart = cal.startOfDay(for: day) + if !excludedDates.contains(dayStart) { + candidates.append(dayStart) } - guard let next = cal.date(byAdding: .day, value: 1, to: day) else { break } - day = next } - return candidates.randomElement() - } else { - let span = cal.dateComponents([.day], from: lower, to: upper).day ?? 0 - let offset = Int.random(in: 0...max(span, 0)) - return cal.date(byAdding: .day, value: offset, to: lower) + guard let next = cal.date(byAdding: .day, value: 1, to: day) else { break } + day = next } + return candidates.randomElement() } private var dateWheelPopover: some View { diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/NewGameSheet.swift @@ -44,7 +44,10 @@ struct NewGameSheet: View { case .imported: ImportedBrowseView(onSelected: handleSelected) case .nyt: - NYTBrowseView(onSelected: handleSelected) + NYTBrowseView( + onSelected: handleSelected, + excludedDates: store.nytPuzzleDatesInLibrary() + ) } } }