commit cb8e7ee0e14c2f4cfcba8038bbc3ddd1fce7009a
parent 383f9235f07e0ede550d3b9edb1d064477c38d7b
Author: Michael Camilleri <[email protected]>
Date: Thu, 7 May 2026 02:51:09 +0900
Update text in notification messages
Diffstat:
5 files changed, 91 insertions(+), 4 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -26,6 +26,7 @@
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */; };
+ 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; };
453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
@@ -42,6 +43,7 @@
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; };
7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; };
7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7422F19AA1F1692A98E3602 /* MoveLog.swift */; };
+ 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; };
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; };
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
@@ -166,8 +168,10 @@
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; };
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; };
+ C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
+ D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnseenMovesTests.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
@@ -230,6 +234,7 @@
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */,
ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */,
+ C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */,
ABB371EF2574E95782CB05FD /* Sync */,
@@ -251,6 +256,7 @@
20B331CC55827FEF3420ABCE /* PlayerSession.swift */,
64C8064F04FC6177D987ACA2 /* Puzzle.swift */,
4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */,
+ D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */,
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */,
DB851649DE78AAAC5A928C52 /* Square.swift */,
B9031A1574C21866940F6A2C /* XD.swift */,
@@ -488,6 +494,7 @@
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */,
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */,
090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */,
+ 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */,
@@ -545,6 +552,7 @@
D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
+ 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */,
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
diff --git a/Crossmate/Models/PuzzleNotificationText.swift b/Crossmate/Models/PuzzleNotificationText.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+enum PuzzleNotificationText {
+ static func title(_ title: String, publisher: String?, date: Date?) -> String {
+ let subtitle = subtitle(publisher: publisher, date: date)
+ guard let subtitle else { return title }
+ return "\(title) – \(subtitle)"
+ }
+
+ private static func subtitle(publisher: String?, date: Date?) -> String? {
+ let formattedDate = date?.formatted(date: .long, time: .omitted)
+ if let publisher, !publisher.isEmpty, let formattedDate {
+ return "\(publisher) · \(formattedDate)"
+ }
+ if let formattedDate {
+ return formattedDate
+ }
+ if let publisher, !publisher.isEmpty {
+ return publisher
+ }
+ return nil
+ }
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -367,9 +367,9 @@ final class AppServices {
}
}
- static func bodyText(for ping: Ping) -> String {
+ nonisolated static func bodyText(for ping: Ping) -> String {
let player = ping.playerName.isEmpty ? "A player" : ping.playerName
- let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle \(ping.puzzleTitle)"
+ let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
switch ping.kind {
case .session:
return "\(player) is solving \(puzzleSuffix)"
@@ -381,7 +381,8 @@ final class AppServices {
switch ping.scope {
case .square: return "\(player) checked a square in \(puzzleSuffix)"
case .word: return "\(player) checked a word in \(puzzleSuffix)"
- case .puzzle, .none: return "\(player) checked \(puzzleSuffix)"
+ case .puzzle: return "\(player) checked all of \(puzzleSuffix)"
+ case .none: return "\(player) checked \(puzzleSuffix)"
}
case .reveal:
switch ping.scope {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -274,7 +274,8 @@ actor SyncEngine {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
- let title = (try? ctx.fetch(req).first?.title) ?? ""
+ let entity = try? ctx.fetch(req).first
+ let title = Self.notificationTitle(for: entity)
return (info, title)
}
guard let zoneAndTitle else { return }
@@ -299,6 +300,15 @@ actor SyncEngine {
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
+ private nonisolated static func notificationTitle(for entity: GameEntity?) -> String {
+ guard let entity else { return "" }
+ return PuzzleNotificationText.title(
+ entity.title ?? "",
+ publisher: entity.cachedPublisher,
+ date: entity.cachedPuzzleDate
+ )
+ }
+
/// Registers a Player record as a pending send. Used by `NameBroadcaster`
/// when the local user renames; one record per (game, authorID), so
/// participants only ever write their own slot.
diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift
@@ -0,0 +1,45 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Puzzle notification text")
+struct PuzzleNotificationTextTests {
+ @Test("Puzzle title includes subtitle separated by an en dash")
+ func titleIncludesSubtitle() {
+ let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2001, month: 1, day: 1))
+
+ let title = PuzzleNotificationText.title("Saturday Puzzle", publisher: nil, date: date)
+
+ #expect(title.contains("Saturday Puzzle – "))
+ #expect(title.contains("2001"))
+ }
+
+ @Test("Notification body quotes puzzle title")
+ func bodyQuotesPuzzleTitle() {
+ let ping = Ping(
+ gameID: UUID(),
+ authorID: "alice",
+ playerName: "Alice",
+ puzzleTitle: "Saturday Puzzle – 1 January 2001",
+ kind: .join,
+ scope: nil
+ )
+
+ #expect(AppServices.bodyText(for: ping) == "Alice joined the puzzle 'Saturday Puzzle – 1 January 2001'")
+ }
+
+ @Test("Puzzle check says all of the puzzle")
+ func puzzleCheckSaysAllOfPuzzle() {
+ let ping = Ping(
+ gameID: UUID(),
+ authorID: "alice",
+ playerName: "Alice",
+ puzzleTitle: "Saturday Puzzle – 1 January 2001",
+ kind: .check,
+ scope: .puzzle
+ )
+
+ #expect(AppServices.bodyText(for: ping) == "Alice checked all of the puzzle 'Saturday Puzzle – 1 January 2001'")
+ }
+}