commit a1d4e680ad9511d79fb402403776a79ea9ea3b3d
parent c563c29c90d683cc79fe8b6beca73d02e7447c6b
Author: Michael Camilleri <[email protected]>
Date: Tue, 28 Apr 2026 12:44:27 +0900
Tweak display of puzzles in GameListView
This commit changes the way that certain puzzles are displayed in
GameListView. It moves the share button back to the overflow menu in
order to provide more space for the puzzle title.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
7 files changed, 88 insertions(+), 21 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -19,6 +19,7 @@ struct Puzzle: Sendable {
}
let title: String
+ let publisher: String?
let author: String?
let date: Date?
let specialKind: Special?
@@ -53,6 +54,7 @@ struct Puzzle: Sendable {
init(xd: XD) {
self.title = xd.title ?? "Untitled"
+ self.publisher = xd.publisher
self.author = xd.author
self.date = xd.date
self.specialKind = xd.specialKind
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -5,6 +5,7 @@ import Foundation
/// bundled puzzles: metadata, grid (with rebus), and across/down clues.
struct XD: Sendable {
let title: String?
+ let publisher: String?
let author: String?
let copyright: String?
let date: Date?
@@ -76,6 +77,7 @@ struct XD: Sendable {
return XD(
title: metadata["Title"],
+ publisher: metadata["Publisher"],
author: metadata["Author"],
copyright: metadata["Copyright"],
date: parseDateHeader(metadata["Date"]),
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -16,6 +16,7 @@ enum GameThumbnailCell: Equatable {
struct GameSummary: Identifiable, Equatable {
let id: UUID
let title: String
+ let publisher: String?
let puzzleDate: Date?
let updatedAt: Date?
let completedAt: Date?
@@ -60,6 +61,7 @@ struct GameSummary: Identifiable, Equatable {
self.id = id
self.title = entity.title ?? "Untitled"
+ self.publisher = puzzle.publisher
self.puzzleDate = puzzle.date
self.updatedAt = entity.updatedAt
self.completedAt = entity.completedAt
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -159,7 +159,8 @@ enum NYTToXDConverter {
// Metadata section
var metadata: [String] = []
- metadata.append("Title: NYT Crossword")
+ metadata.append("Title: \(title(forPublicationDate: publicationDate))")
+ metadata.append("Publisher: New York Times")
if !publicationDate.isEmpty {
metadata.append("Date: \(publicationDate)")
}
@@ -204,6 +205,38 @@ enum NYTToXDConverter {
return sections.joined(separator: "\n\n\n")
}
+ private static func title(forPublicationDate publicationDate: String) -> String {
+ guard let date = date(fromPublicationDate: publicationDate) else {
+ return "NYT Crossword"
+ }
+
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "America/New_York")
+ formatter.dateFormat = "EEEE"
+ return "\(formatter.string(from: date)) Crossword"
+ }
+
+ private static func date(fromPublicationDate publicationDate: String) -> Date? {
+ let trimmed = publicationDate.trimmingCharacters(in: .whitespaces)
+ guard let match = trimmed.firstMatch(of: /^(\d{4})-(\d{2})-(\d{2})$/),
+ let year = Int(match.1),
+ let month = Int(match.2),
+ let day = Int(match.3)
+ else { return nil }
+
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt
+ var comps = DateComponents()
+ comps.calendar = calendar
+ comps.timeZone = calendar.timeZone
+ comps.year = year
+ comps.month = month
+ comps.day = day
+ return calendar.date(from: comps)
+ }
+
/// Builds groups of cross-referenced clues from the v6 per-clue
/// `relatives` arrays. Two rules admit a group, everything else is
/// discarded:
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -188,7 +188,10 @@ private struct GameRowView: View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(game.title)
- .font(.headline)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ .truncationMode(.tail)
if game.isShared {
Image(systemName: "person.2.fill")
.font(.caption)
@@ -197,7 +200,14 @@ private struct GameRowView: View {
}
if let puzzleDate = game.puzzleDate {
Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year())
- .font(.subheadline)
+ .font(.footnote)
+ }
+ if let publisher = game.publisher {
+ Text(publisher)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
}
if let date = game.updatedAt {
Text(date, format: Calendar.current.isDateInToday(date) ? .dateTime.hour().minute() : .dateTime.year().month().day())
@@ -206,20 +216,13 @@ private struct GameRowView: View {
}
}
Spacer()
- if game.isOwned {
+ Menu {
Button {
isShowingShareSheet = true
} label: {
- Image(systemName: "square.and.arrow.up")
- .font(.body)
- .frame(width: 32, height: 32)
- .contentShape(Rectangle())
+ Label("Share", systemImage: "square.and.arrow.up")
}
- .buttonStyle(.borderless)
- .tint(.secondary)
- .compositingGroup()
- }
- Menu {
+ .disabled(!game.isOwned)
Button { onLeave() } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") }
.disabled(!(!game.isOwned && game.isShared))
Button { onResume() } label: { Label("Resume", systemImage: "square.and.pencil") }
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -29,7 +29,13 @@ struct PuzzleView: View {
private var titleParts: TitleParts {
let title = session.puzzle.title
- let subtitle = session.puzzle.date?.formatted(date: .complete, time: .omitted)
+ let formattedDate = session.puzzle.date?.formatted(date: .complete, time: .omitted)
+ let subtitle: String?
+ if let publisher = session.puzzle.publisher, let formattedDate {
+ subtitle = "\(publisher) · \(formattedDate)"
+ } else {
+ subtitle = formattedDate
+ }
return TitleParts(title: title, subtitle: subtitle)
}
@@ -179,11 +185,6 @@ struct PuzzleView: View {
}
Section {
- Button("Leave Game", role: .destructive) {
- isConfirmingLeave = true
- }
- .disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil)
-
if shareController != nil {
Button {
isShowingShareSheet = true
@@ -192,6 +193,11 @@ struct PuzzleView: View {
}
.disabled(!session.mutator.isOwned)
}
+
+ Button("Leave Game", role: .destructive) {
+ isConfirmingLeave = true
+ }
+ .disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil)
}
} label: {
Label("Players", systemImage: "person.2")
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -54,9 +54,14 @@ struct NYTToXDConverterTests {
/// Extracts the value after `Relatives: ` in an `.xd` source, or nil if
/// the header isn't present.
private func relativesHeader(in xd: String) -> String? {
+ header("Relatives", in: xd)
+ }
+
+ private func header(_ name: String, in xd: String) -> String? {
+ let prefix = "\(name): "
for line in xd.split(separator: "\n") {
- if line.hasPrefix("Relatives: ") {
- return String(line.dropFirst("Relatives: ".count))
+ if line.hasPrefix(prefix) {
+ return String(line.dropFirst(prefix.count))
}
}
return nil
@@ -64,6 +69,20 @@ struct NYTToXDConverterTests {
// MARK: - Header emission
+ @Test("NYT metadata uses weekday title and publisher")
+ func nytMetadataTitleAndPublisher() throws {
+ let data = try puzzleJSON(relatives: [nil, nil, nil, nil, nil, nil])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(header("Title", in: xd) == "Wednesday Crossword")
+ #expect(header("Publisher", in: xd) == "New York Times")
+ #expect(header("Date", in: xd) == "2025-01-01")
+
+ let parsed = try XD.parse(xd)
+ let puzzle = Puzzle(xd: parsed)
+ #expect(puzzle.title == "Wednesday Crossword")
+ #expect(puzzle.publisher == "New York Times")
+ }
+
@Test("Revealer with ≥2 relatives produces a group")
func revealerGroup() throws {
// 1A (index 0) references 4A (1) and 5A (2).