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:
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 {