crossmate

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

commit 4e4af5250f9160e80fb7cca1f8bc2e098043cf2b
parent 392b1bce068e6c348d3301719573ea14caaddc0a
Author: Michael Camilleri <[email protected]>
Date:   Tue, 14 Apr 2026 08:20:26 +0900

Add date field to XD struct

In certain puzzles, the date is the identifying element (this is
particularly the case in newspaper-originating puzzles). This commit
adds a `date` field to the XD struct.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
MCrossmate/Models/Puzzle.swift | 2++
MCrossmate/Models/XD.swift | 19+++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 2++
MCrossmate/Services/NYTToXDConverter.swift | 8++++----
MCrossmate/Views/GameListView.swift | 4++++
MCrossmate/Views/PuzzleView.swift | 23+++--------------------
6 files changed, 34 insertions(+), 24 deletions(-)

diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -20,6 +20,7 @@ struct Puzzle: Sendable { let title: String let author: String? + let date: Date? let specialKind: Special? let width: Int let height: Int @@ -49,6 +50,7 @@ struct Puzzle: Sendable { init(xd: XD) { self.title = xd.title ?? "Untitled" self.author = xd.author + self.date = xd.date self.specialKind = xd.specialKind self.width = xd.width self.height = xd.height diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -7,6 +7,7 @@ struct XD: Sendable { let title: String? let author: String? let copyright: String? + let date: Date? let specialKind: Puzzle.Special? let width: Int let height: Int @@ -67,6 +68,7 @@ struct XD: Sendable { title: metadata["Title"], author: metadata["Author"], copyright: metadata["Copyright"], + date: parseDateHeader(metadata["Date"]), specialKind: specialKind, width: width, height: height, @@ -161,6 +163,23 @@ struct XD: Sendable { return map } + /// Parses a `Date:` header value as strict ISO `YYYY-MM-DD`. Returns + /// `nil` if the value is missing, empty, or in any other format. + private static func parseDateHeader(_ value: String?) -> Date? { + guard let value else { return nil } + let trimmed = value.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 comps = DateComponents() + comps.year = year + comps.month = month + comps.day = day + return Calendar(identifier: .gregorian).date(from: comps) + } + /// Parses a `Special:` header value into a `Puzzle.Special` kind. The /// .xd spec recognises `shaded` and `circle` as the two values; we accept /// either case-insensitively and treat anything else as no special kind. diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -39,6 +39,7 @@ final class GameStore { struct GameSummary: Identifiable, Equatable { let id: UUID let title: String + let puzzleDate: Date? let updatedAt: Date? let completedAt: Date? /// Grid dimensions + cell states for the thumbnail. @@ -107,6 +108,7 @@ final class GameStore { return GameSummary( id: id, title: entity.title ?? "Untitled", + puzzleDate: puzzle.date, updatedAt: entity.updatedAt, completedAt: entity.completedAt, gridWidth: puzzle.width, diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -151,10 +151,10 @@ enum NYTToXDConverter { // Metadata section var metadata: [String] = [] - let title = constructors.isEmpty - ? "NYT Crossword \(publicationDate)" - : "NYT Crossword \(publicationDate)" - metadata.append("Title: \(title)") + metadata.append("Title: NYT Crossword") + if !publicationDate.isEmpty { + metadata.append("Date: \(publicationDate)") + } if !constructors.isEmpty { metadata.append("Author: \(constructors.joined(separator: ", "))") } diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -181,6 +181,10 @@ private struct GameRowView: View { VStack(alignment: .leading, spacing: 2) { Text(game.title) .font(.headline) + if let puzzleDate = game.puzzleDate { + Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) + .font(.subheadline) + } if let date = game.updatedAt { Text(date, format: Calendar.current.isDateInToday(date) ? .dateTime.hour().minute() : .dateTime.year().month().day()) .font(.caption) diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -10,26 +10,9 @@ struct PuzzleView: View { } private var titleParts: TitleParts { - let raw = session.puzzle.title - guard let match = raw.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 TitleParts(title: raw, subtitle: nil) } - var comps = DateComponents() - comps.year = year - comps.month = month - comps.day = day - guard let date = Calendar.current.date(from: comps) else { - return TitleParts(title: raw, subtitle: nil) - } - let formatted = date.formatted(date: .complete, time: .omitted) - let prefix = String(raw[..<match.range.lowerBound]) - .trimmingCharacters(in: .whitespaces) - if prefix.isEmpty { - return TitleParts(title: formatted, subtitle: nil) - } - return TitleParts(title: prefix, subtitle: formatted) + let title = session.puzzle.title + let subtitle = session.puzzle.date?.formatted(date: .complete, time: .omitted) + return TitleParts(title: title, subtitle: subtitle) } var body: some View {