commit ec59ffe353eeee1ed7f51fe49e594b30797dd41f
parent c55aee18a2ced9073240ccf16761f00334f54c07
Author: Michael Camilleri <[email protected]>
Date: Sun, 7 Jun 2026 13:54:41 +0900
Add random puzzle button to provider's browse view
This commit adds a secondary button below the Start button that fetches
a random puzzle for a chosen weekday and year. The weekday ('Any Day' or
a full day name) and year are picked via inline menus in the button
label, and the date is randomised within the supported range (>= 2001,
<= today in New York), reusing the existing fetch path.
This commit also has today's date preselected when the view opens
so the Start button is ready without first tapping a cell.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 146 insertions(+), 1 deletion(-)
diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift
@@ -6,18 +6,24 @@ struct NYTBrowseView: View {
@Environment(\.nytPuzzleFetcher) private var fetcher
@Environment(NYTAuthService.self) private var nytAuth
@State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth()
- @State private var selectedDate: Date?
+ @State private var selectedDate: Date? = NYTBrowseView.today()
@State private var isLoading = false
@State private var errorMessage: String?
@State private var sessionExpired = false
@State private var showingMonthPicker = false
@State private var pickerDate: Date = NYTBrowseView.startOfCurrentMonth()
+ @State private var randomWeekday: Int? = nil
+ @State private var randomYear: Int = NYTBrowseView.currentYear()
private static let nytTimeZone = TimeZone(identifier: "America/New_York")!
private static var nytCalendar: Calendar {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = nytTimeZone
+ // Without an explicit locale the calendar uses a "fixed" locale whose
+ // weekdaySymbols are abbreviated ("Mon"); set the current locale so the
+ // weekday menu shows full names.
+ cal.locale = .current
return cal
}
@@ -35,6 +41,40 @@ struct NYTBrowseView: View {
return cal.date(from: comps) ?? Date()
}
+ private static func today() -> Date {
+ nytCalendar.startOfDay(for: Date())
+ }
+
+ private static func currentYear() -> Int {
+ nytCalendar.component(.year, from: Date())
+ }
+
+ private static let selectableYears: [Int] = {
+ let start = nytCalendar.component(.year, from: minDate)
+ let end = nytCalendar.component(.year, from: Date())
+ return Array(start...end)
+ }()
+
+ // Full weekday names (index 0 = Sunday) in the user's locale. Sourced from a
+ // DateFormatter rather than nytCalendar, whose unset "fixed" locale yields
+ // abbreviated symbols ("Mon").
+ private static let fullWeekdaySymbols: [String] = {
+ let formatter = DateFormatter()
+ formatter.locale = .current
+ return formatter.weekdaySymbols
+ }()
+
+ private static func weekdayName(_ weekday: Int) -> String {
+ // weekday uses Gregorian numbering: 1 = Sunday … 7 = Saturday
+ fullWeekdaySymbols[(weekday - 1) % fullWeekdaySymbols.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
+ // of Monday … Sunday, with "Any Day" (declared first) pinned to the bottom.
+ private static let weekdayMenuOrder = [1, 7, 6, 5, 4, 3, 2]
+
private let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
@@ -45,6 +85,7 @@ struct NYTBrowseView: View {
dayGrid
Spacer()
confirmButton
+ randomButton
}
.padding()
.disabled(isLoading)
@@ -175,6 +216,110 @@ struct NYTBrowseView: View {
return "Start \(selectedDate.formatted(style))"
}
+ private var randomButton: some View {
+ HStack(spacing: 4) {
+ Text("Random")
+ weekdayMenu
+ Text("in")
+ yearMenu
+ }
+ .font(.headline)
+ .lineLimit(1)
+ .minimumScaleFactor(0.7)
+ .foregroundStyle(Self.randomButtonForeground)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(Self.randomButtonTint, in: .capsule)
+ .contentShape(.capsule)
+ .onTapGesture {
+ if let date = randomDate() {
+ fetch(date)
+ }
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityAddTraits(.isButton)
+ }
+
+ private static let randomButtonTint = Color.accentColor.opacity(0.15)
+ private static let randomButtonForeground = Color.primary
+
+ private var weekdayMenu: some View {
+ Menu {
+ Button("Any Day") { randomWeekday = nil }
+ ForEach(Self.weekdayMenuOrder, id: \.self) { weekday in
+ Button(Self.weekdayName(weekday)) { randomWeekday = weekday }
+ }
+ } label: {
+ menuLabel(randomWeekday.map(Self.weekdayName) ?? "day")
+ }
+ }
+
+ private var yearMenu: some View {
+ Menu {
+ ForEach(Self.selectableYears.reversed(), id: \.self) { year in
+ Button(String(year)) { randomYear = year }
+ }
+ } label: {
+ menuLabel(String(randomYear))
+ }
+ }
+
+ private func menuLabel(_ text: String) -> some View {
+ HStack(spacing: 2) {
+ Text(text)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .fill(Color.accentColor)
+ .frame(height: 1)
+ .offset(y: 4)
+ }
+ Image(systemName: "chevron.down")
+ .font(.caption2.weight(.bold))
+ .foregroundStyle(Color.accentColor)
+ }
+ .foregroundStyle(Self.randomButtonForeground)
+ }
+
+ /// Picks a random valid puzzle date in `randomYear`, optionally constrained to
+ /// `randomWeekday`, clamped to the supported range (>= minDate, <= today).
+ private func randomDate() -> Date? {
+ let cal = Self.nytCalendar
+ let minStart = cal.startOfDay(for: Self.minDate)
+ let todayStart = cal.startOfDay(for: Date())
+
+ var startComps = DateComponents()
+ startComps.year = randomYear
+ startComps.month = 1
+ startComps.day = 1
+ var endComps = DateComponents()
+ endComps.year = randomYear
+ endComps.month = 12
+ endComps.day = 31
+ guard let yearStart = cal.date(from: startComps),
+ let yearEnd = cal.date(from: endComps) else { return nil }
+
+ let lower = max(cal.startOfDay(for: yearStart), minStart)
+ 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)
+ }
+ 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)
+ }
+ }
+
private var dateWheelPopover: some View {
VStack(spacing: 28) {
HStack {