commit 7e4231a4ffbe21e9f6f7f670e658c1fcbe9275b6
parent 9f341acb78b1cf9f03644e39fb8569c99cd2184a
Author: Michael Camilleri <[email protected]>
Date: Mon, 15 Jun 2026 16:33:37 +0900
Highlight cells a peer changed while away
When the user returns to a shared puzzle, cells a collaborator
filled or cleared since the user last viewed the board now appear
with a fading border in the writing player's colour, so it is clear
at a glance what changed — and who changed it — while the user was
away.
This commit captures the change set once, on the same open 'arm'
beat that reveals a header banner, so the borders and any session
summary surface together rather than as two unrelated animations.
The set is computed from the merged moves via provenance rather than
the squares grid: a cleared cell drops its preserved letter author,
but the writing player is still recorded on the winning move, so
clears stay attributed alongside fills. The user's own edits are
excluded, and the borders persist until the first interaction, which
acknowledges them and fades them out.
The baseline for 'since the user last viewed' is a device-local,
never-synced per-game timestamp, modelled on the existing cursor
store and stamped on leave or background. A first-ever open has no
baseline, so it establishes one silently instead of flagging the
whole board. Moves that arrive after the capture are treated as live
activity and left to the peer cursor tints, not added to the
away-summary.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
10 files changed, 382 insertions(+), 18 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -28,6 +28,7 @@
18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; };
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; };
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; };
+ 1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */; };
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; };
@@ -75,6 +76,7 @@
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */; };
+ 6E36ED34ACF047BABB3E2D69 /* RecentChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */; };
6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */; };
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; };
740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; };
@@ -111,6 +113,7 @@
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */; };
+ 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; };
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; };
@@ -285,6 +288,7 @@
5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRecordPresenceTests.swift; sourceTree = "<group>"; };
5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; };
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayCacheTests.swift; sourceTree = "<group>"; };
+ 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChanges.swift; sourceTree = "<group>"; };
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; };
61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
@@ -308,6 +312,7 @@
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; };
800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; };
802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoiningPuzzleView.swift; sourceTree = "<group>"; };
+ 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChangesTests.swift; sourceTree = "<group>"; };
847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControlsTests.swift; sourceTree = "<group>"; };
86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; };
87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; };
@@ -349,6 +354,7 @@
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; };
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; };
B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; };
+ B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewedStore.swift; sourceTree = "<group>"; };
B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveTests.swift; sourceTree = "<group>"; };
BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; };
BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; };
@@ -424,6 +430,7 @@
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */,
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */,
CFC4FF046BF772646B5CA73F /* Presence.swift */,
+ 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */,
BD63A9B20168F3B81AF4348F /* RecordApplier.swift */,
5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
@@ -483,6 +490,7 @@
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */,
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */,
362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */,
+ 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */,
443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */,
@@ -507,6 +515,7 @@
9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */,
+ B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */,
E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */,
7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
@@ -866,6 +875,7 @@
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */,
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */,
+ 6E36ED34ACF047BABB3E2D69 /* RecentChangesTests.swift in Sources */,
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */,
@@ -928,6 +938,7 @@
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
+ 1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */,
4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */,
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */,
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */,
@@ -975,6 +986,7 @@
88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */,
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
+ 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */,
D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */,
D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */,
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -602,7 +602,17 @@ private struct PuzzleDisplayView: View {
)
return await services.replays.loadReplay(gameID: gameID)
}
- }
+ },
+ loadRecentChanges: {
+ // Cells a peer changed since this device last viewed the
+ // game. A missing timestamp means a first-ever open —
+ // establish the baseline silently rather than flag the
+ // whole board (the leave/background path below stamps it).
+ guard let since = services.gameViewedStore.lastViewed(forGame: gameID)
+ else { return [:] }
+ return store.recentlyChangedCells(forGame: gameID, since: since)
+ },
+ markPuzzleViewed: { stampPuzzleViewed() }
)
} else if let loadError {
ContentUnavailableView(
@@ -752,6 +762,9 @@ private struct PuzzleDisplayView: View {
// `.active` re-arms the loop via `startEngagementIfPossible`.
services.engagement.cancelEngagementReconnectRetry(gameID: id)
Task { await services.publishReadCursor(for: id, mode: .currentTime) }
+ // Backgrounding counts as leaving for the away-change baseline:
+ // anything a peer does after this should flag on the next open.
+ stampPuzzleViewed()
case .inactive:
break
@unknown default:
@@ -772,6 +785,9 @@ private struct PuzzleDisplayView: View {
// session to pause on the way out.
let owesEndPush = services.sessions.notePuzzleClosed(gameID: id)
services.engagement.scheduleEngagementEnd(gameID: id)
+ // Navigating away is a leave: stamp the away-change baseline so the
+ // next open diffs against now.
+ stampPuzzleViewed()
Task {
await movesUpdater.flush()
// The clear-cursor and close-lease writes both enqueue without
@@ -845,6 +861,16 @@ private struct PuzzleDisplayView: View {
}
}
+ /// Records that this device has now viewed the game up to the current
+ /// moment, the baseline the next open diffs against for "changed while you
+ /// were away" borders. Device-local; only shared games are tracked (solo
+ /// games have no peers to surface). Called on the player's first
+ /// interaction (via `PuzzleView`'s acknowledgement) and on leave/background.
+ private func stampPuzzleViewed() {
+ guard session?.mutator.isShared == true else { return }
+ services.gameViewedStore.setLastViewed(Date(), forGame: gameID)
+ }
+
/// Initialises shared-game state (roster, selection publishing, name broadcast) for
/// the open session. Called when the puzzle first appears as shared, and
/// again if a previously-solo game becomes shared mid-session.
diff --git a/Crossmate/Models/GameViewedStore.swift b/Crossmate/Models/GameViewedStore.swift
@@ -0,0 +1,55 @@
+import Foundation
+
+/// Persists, per game, the wall-clock time this player last viewed the puzzle
+/// in `UserDefaults`.
+///
+/// Device-local by design — it is never synced to CloudKit. It records when
+/// *this* player last had the board open so that, on reopening, cells a peer
+/// filled or cleared while they were away can be highlighted. This is unrelated
+/// to the synced `Player.readAt`/`notifiedThrough` notification bookkeeping; it
+/// is purely local viewing state, like `GameCursorStore`.
+///
+/// Layout under the `"gameLastViewed"` key:
+/// `[gameID.uuidString: TimeInterval]`
+/// where the value is `Date.timeIntervalSinceReferenceDate`.
+@MainActor
+final class GameViewedStore {
+ private let defaults: UserDefaults
+ private let defaultsKey = "gameLastViewed"
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ // MARK: - Read
+
+ /// When this player last viewed the game, or `nil` if they never have —
+ /// the first open establishes the baseline rather than flagging the whole
+ /// board as unseen.
+ func lastViewed(forGame gameID: UUID) -> Date? {
+ guard let interval = rawStore[gameID.uuidString] else { return nil }
+ return Date(timeIntervalSinceReferenceDate: interval)
+ }
+
+ // MARK: - Write
+
+ func setLastViewed(_ date: Date, forGame gameID: UUID) {
+ var s = rawStore
+ s[gameID.uuidString] = date.timeIntervalSinceReferenceDate
+ rawStore = s
+ }
+
+ func clearLastViewed(forGame gameID: UUID) {
+ var s = rawStore
+ guard s[gameID.uuidString] != nil else { return }
+ s.removeValue(forKey: gameID.uuidString)
+ rawStore = s
+ }
+
+ // MARK: - Private
+
+ private var rawStore: [String: TimeInterval] {
+ get { defaults.dictionary(forKey: defaultsKey) as? [String: TimeInterval] ?? [:] }
+ set { defaults.set(newValue, forKey: defaultsKey) }
+ }
+}
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -81,6 +81,19 @@ final class PlayerSession {
var puzzle: Puzzle { game.puzzle }
+ /// Cells a peer filled or cleared since this player last viewed the puzzle,
+ /// each mapped to the author who wrote the change. Captured once on the
+ /// open "arm" beat (see `PuzzleView`) and rendered as fading author-coloured
+ /// borders by `GridView`; cleared on the player's first interaction, which
+ /// is the acknowledgement. Empty outside that window.
+ var recentChanges: [GridPosition: String] = [:]
+
+ /// Fired when `recentChanges` is acknowledged (cleared) by an interaction,
+ /// so the owner can advance this game's last-viewed timestamp. Unset for
+ /// solo/test sessions.
+ @ObservationIgnored
+ var onRecentChangesAcknowledged: (() -> Void)?
+
init(game: Game, mutator: GameMutator, cursorStore: GameCursorStore? = nil) {
self.game = game
self.mutator = mutator
@@ -135,6 +148,7 @@ final class PlayerSession {
// MARK: - Selection
private func selectionDidChange() {
+ acknowledgeRecentChangesIfNeeded()
publishCurrentSelection()
cursorStore?.setCursor(
.init(row: selectedRow, col: selectedCol, direction: direction),
@@ -142,6 +156,16 @@ final class PlayerSession {
)
}
+ /// Clears the "changed while you were away" borders on the first selection
+ /// change after they were captured — moving the cursor or typing (which
+ /// advances it) both route through here — and notifies the owner so the
+ /// last-viewed timestamp advances. A no-op while `recentChanges` is empty.
+ private func acknowledgeRecentChangesIfNeeded() {
+ guard !recentChanges.isEmpty else { return }
+ recentChanges = [:]
+ onRecentChangesAcknowledged?()
+ }
+
func publishCurrentSelection() {
guard let onSelectionChanged else { return }
guard let track = currentCursorTrack else { return }
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1464,6 +1464,22 @@ final class GameStore {
return GridStateMerger.mergeWithProvenance(values).values.map(\.cell)
}
+ /// Grid cells a *peer* filled or cleared since `since`, each mapped to the
+ /// author who wrote the change — the data behind the "changed while you were
+ /// away" borders. Merges every contributor's moves (not just one author's),
+ /// so a peer's clear is attributed to them even though it leaves no
+ /// preserved cell author. The local player's own edits are excluded. Returns
+ /// empty when the local author is unknown (nothing to compare against).
+ func recentlyChangedCells(forGame gameID: UUID, since: Date) -> [GridPosition: String] {
+ guard let localAuthorID = authorIDProvider() else { return [:] }
+ let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
+ let entities = (try? context.fetch(request)) ?? []
+ let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) }
+ guard !values.isEmpty else { return [:] }
+ return RecentChanges.changedCells(in: values, since: since, excludingAuthor: localAuthorID)
+ }
+
/// Sender-side measurements describing *why* the pause-push counts for
/// `(gameID, authorID)` came out as they did. Mirrors the set the count
/// path (`mergedAuthorCells`) iterates, then breaks it down against the
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -59,6 +59,9 @@ final class AppServices {
let friendController: FriendController
let gameArchiver: GameArchiver
let cursorStore: GameCursorStore
+ /// Device-local record of when each game was last viewed; drives the
+ /// "changed while you were away" cell borders. Never synced.
+ let gameViewedStore: GameViewedStore
let engagementStore: EngagementStore
let cloudService: CloudService
let importService: ImportService
@@ -233,11 +236,14 @@ final class AppServices {
let cursorStore = GameCursorStore()
self.cursorStore = cursorStore
+ let gameViewedStore = GameViewedStore()
+ self.gameViewedStore = gameViewedStore
let engagementStore = EngagementStore()
self.engagementStore = engagementStore
let onGameDeletedHandler = Self.makeOnGameDeleted(
syncEngine: syncEngine,
- cursorStore: cursorStore
+ cursorStore: cursorStore,
+ viewedStore: gameViewedStore
)
let store = GameStore(
@@ -1816,10 +1822,12 @@ final class AppServices {
/// cleanup: they are derived on the fly, never persisted per game.)
static func makeOnGameDeleted(
syncEngine: SyncEngine,
- cursorStore: GameCursorStore? = nil
+ cursorStore: GameCursorStore? = nil,
+ viewedStore: GameViewedStore? = nil
) -> (GameCloudDeletion) -> Void {
{ deletion in
cursorStore?.clearCursor(forGame: deletion.gameID)
+ viewedStore?.clearLastViewed(forGame: deletion.gameID)
Task { await syncEngine.enqueueDeleteGame(deletion) }
}
}
diff --git a/Crossmate/Sync/RecentChanges.swift b/Crossmate/Sync/RecentChanges.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+/// Computes which grid cells changed since a cutoff, attributed to the player
+/// who wrote each change. Pure so it runs off the already-merged moves without
+/// touching Core Data and is unit-testable in isolation.
+///
+/// Fills *and* clears qualify. A clear drops the cell's preserved letter author
+/// (`TimestampedCell.authorID` becomes `nil`), so the squares grid can't say who
+/// emptied a cell — but the writing user is still recorded on the winning move,
+/// which is what `GridStateMerger.mergeWithProvenance` surfaces as
+/// `writerAuthorID`. That's the attribution this returns.
+enum RecentChanges {
+ /// Positions whose winning move landed strictly after `since` and was
+ /// written by someone other than `excluded`, each mapped to that writer's
+ /// `authorID`. Last-writer-wins (inside `mergeWithProvenance`) decides the
+ /// winning touch per cell, so a cell reverted back to its old value by a
+ /// later move is attributed to whoever made that later move.
+ static func changedCells(
+ in moves: [MovesValue],
+ since: Date,
+ excludingAuthor excluded: String
+ ) -> [GridPosition: String] {
+ var result: [GridPosition: String] = [:]
+ for (position, provenance) in GridStateMerger.mergeWithProvenance(moves) {
+ guard provenance.cell.updatedAt > since else { continue }
+ guard provenance.writerAuthorID != excluded else { continue }
+ result[position] = provenance.writerAuthorID
+ }
+ return result
+ }
+}
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -62,6 +62,9 @@ struct GridView: View {
replayCursor: replayCursor,
replayPlayheadTint: playheadTint
)
+ if !isReplaying {
+ RecentChangeBorders(session: session, roster: roster, spacing: spacing)
+ }
PuzzleGridLayout(columns: width, rows: height, spacing: spacing) {
ForEach(0..<(width * height), id: \.self) { index in
let r = index / width
@@ -440,3 +443,78 @@ private struct LocalCursorTints: View {
))
}
}
+
+/// Fading author-coloured borders on cells a peer filled or cleared since this
+/// player last viewed the puzzle. The set is captured once on the open arm beat
+/// (`PlayerSession.recentChanges`, populated by `PuzzleView`); this layer reveals
+/// it and fades it out when the player's first interaction clears the set. Like
+/// the cursor-tint layers it reads only its own slice of `session`, so it is the
+/// only view that repaints when the change set changes. Drawn over the cursor
+/// tints but behind the cells, matching `LocalCursorTints`' related-cell borders.
+private struct RecentChangeBorders: View {
+ let session: PlayerSession
+ let roster: PlayerRoster
+ let spacing: CGFloat
+
+ /// The resolved strokes currently drawn. Held locally so they stay on
+ /// screen through the fade-out after `recentChanges` is cleared.
+ @State private var shown: [GridPosition: Color] = [:]
+ @State private var visible = false
+
+ var body: some View {
+ Canvas { context, size in
+ guard !shown.isEmpty else { return }
+ let geometry = PuzzleGridGeometry(
+ size: size,
+ columns: session.puzzle.width,
+ rows: session.puzzle.height,
+ spacing: spacing
+ )
+ for (pos, color) in shown {
+ // Inset by half the line width so the stroke sits inside the
+ // cell, matching `LocalCursorTints`' related-cell border.
+ let rect = geometry.cellRect(row: pos.row, col: pos.col).insetBy(dx: 1.5, dy: 1.5)
+ context.stroke(Path(rect), with: .color(color), lineWidth: 3)
+ }
+ }
+ .opacity(visible ? 1 : 0)
+ .animation(.easeOut(duration: 0.45), value: visible)
+ .allowsHitTesting(false)
+ .onAppear { apply(session.recentChanges) }
+ .onChange(of: session.recentChanges) { _, changes in apply(changes) }
+ }
+
+ private func apply(_ changes: [GridPosition: String]) {
+ if changes.isEmpty {
+ // Fade out, then drop the strokes once the animation has finished so
+ // they remain visible while the opacity animates down.
+ visible = false
+ Task {
+ try? await Task.sleep(for: .milliseconds(500))
+ if session.recentChanges.isEmpty { shown = [:] }
+ }
+ } else {
+ shown = resolve(changes)
+ visible = true
+ }
+ }
+
+ /// Maps each changed cell to its writer's colour, dropping out-of-bounds or
+ /// block positions defensively. A writer no longer in the roster falls back
+ /// to a neutral border rather than being skipped.
+ private func resolve(_ changes: [GridPosition: String]) -> [GridPosition: Color] {
+ let colorByAuthor = Dictionary(
+ roster.entries.map { ($0.authorID, $0.color.tint) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ let width = session.puzzle.width
+ let height = session.puzzle.height
+ var result: [GridPosition: Color] = [:]
+ for (pos, authorID) in changes {
+ guard pos.row >= 0, pos.row < height, pos.col >= 0, pos.col < width else { continue }
+ guard !session.puzzle.cells[pos.row][pos.col].isBlock else { continue }
+ result[pos] = colorByAuthor[authorID] ?? Color.secondary
+ }
+ return result
+ }
+}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -32,6 +32,13 @@ struct PuzzleView: View {
/// Loads the finished game's merged journal for the finish-banner replay
/// scrubber. Defaults to `.unavailable` so previews/tests need not wire it.
var loadReplay: () async -> JournalReplayResult = { .unavailable }
+ /// Cells a peer filled or cleared since this player last viewed the puzzle,
+ /// mapped to the writing author. Read once on the open arm beat. Defaults to
+ /// empty so previews/tests need not wire it.
+ var loadRecentChanges: () -> [GridPosition: String] = { [:] }
+ /// Stamps this game's last-viewed timestamp (device-local). Called when the
+ /// away-change borders are acknowledged. Defaults to a no-op.
+ var markPuzzleViewed: () -> Void = {}
@Environment(InputMonitor.self) private var inputMonitor
@Environment(PlayerPreferences.self) private var preferences
@Environment(AnnouncementCenter.self) private var announcements
@@ -50,6 +57,9 @@ struct PuzzleView: View {
@State private var hasSolved = false
@State private var replay = ReplayControls()
@State private var padLayout: PadLayout?
+ /// The shared open "arm" beat: flips a moment after open so the banner and
+ /// the "changed while you were away" borders reveal together.
+ @State private var isArmed = false
@Environment(\.engagementStatus) private var engagementStatus
private enum PadLayout {
@@ -160,6 +170,22 @@ struct PuzzleView: View {
} action: { newSize in
updateLayoutTrait(for: newSize)
}
+ .onAppear {
+ session.onRecentChangesAcknowledged = markPuzzleViewed
+ }
+ .task(id: session.mutator.gameID) {
+ // The shared open beat. A short hold lets the puzzle settle and the
+ // on-open sync land; then we arm the banner and capture — once —
+ // which cells a peer changed while we were away, so both reveal
+ // together. Moves that arrive after this are live activity (peer
+ // cursor tints), not part of the away-summary.
+ isArmed = false
+ try? await Task.sleep(for: .milliseconds(750))
+ isArmed = true
+ if session.mutator.isShared {
+ session.recentChanges = loadRecentChanges()
+ }
+ }
}
private var phoneLayout: some View {
@@ -288,7 +314,8 @@ struct PuzzleView: View {
subtitle: titleParts.subtitle,
showsScoreboard: padLayout == nil,
gameID: session.mutator.gameID,
- isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true
+ isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true,
+ isArmed: isArmed
)
GridView(
session: session,
@@ -1160,14 +1187,15 @@ private struct PuzzleHeader: View {
let showsScoreboard: Bool
let gameID: UUID
let isEngagementLive: Bool
+ /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the
+ /// grid's "changed while you were away" borders reveal together. Until it
+ /// flips (a moment after open), the title is the only thing on screen;
+ /// then banner posts — including a session summary that arrived during the
+ /// hold — animate in.
+ let isArmed: Bool
@Environment(AnnouncementCenter.self) private var announcements
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var selection: Page = .title
- /// Holds off looking at the announcement queue for a moment after
- /// open, so the title is the only thing on screen during the
- /// puzzle-open beat. Once it flips, banner posts (including the
- /// session summary that arrived during the hold) animate in.
- @State private var announcementsArmed = false
private enum Page: Hashable {
case title
@@ -1222,7 +1250,7 @@ private struct PuzzleHeader: View {
}
var body: some View {
- let visibleAnnouncement = announcementsArmed
+ let visibleAnnouncement = isArmed
? announcements.current(forGame: gameID)
: nil
Group {
@@ -1251,14 +1279,6 @@ private struct PuzzleHeader: View {
.padding(.bottom, 14)
.animation(.easeInOut(duration: 0.3), value: visibleAnnouncement)
.animation(.easeInOut(duration: 0.2), value: isEngagementLive)
- .task {
- // 1-second hold lets the puzzle settle visually before any
- // banner animates in. Posts that arrive during the hold are
- // applied on arming, so a session summary still shows — it
- // just shows after the open beat instead of racing it.
- try? await Task.sleep(for: .milliseconds(750))
- announcementsArmed = true
- }
}
private var headerPages: some View {
diff --git a/Tests/Unit/RecentChangesTests.swift b/Tests/Unit/RecentChangesTests.swift
@@ -0,0 +1,94 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("RecentChanges.changedCells")
+struct RecentChangesTests {
+
+ private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!
+
+ /// One author/device's contribution. Each cell is `(row, col, letter,
+ /// updatedAt)`; an empty `letter` models a clear.
+ private func view(
+ author: String,
+ device: String = "d1",
+ cells: [(row: Int, col: Int, letter: String, updatedAt: Date)]
+ ) -> MovesValue {
+ var dict: [GridPosition: TimestampedCell] = [:]
+ for entry in cells {
+ dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell(
+ letter: entry.letter,
+ mark: .none,
+ updatedAt: entry.updatedAt,
+ // A clear leaves no preserved cell author, mirroring the app.
+ authorID: entry.letter.isEmpty ? nil : author
+ )
+ }
+ return MovesValue(
+ gameID: gameID,
+ authorID: author,
+ deviceID: device,
+ cells: dict,
+ updatedAt: cells.map(\.updatedAt).max() ?? .distantPast
+ )
+ }
+
+ private let cutoff = Date(timeIntervalSinceReferenceDate: 1_000)
+ private var before: Date { cutoff.addingTimeInterval(-60) }
+ private var after: Date { cutoff.addingTimeInterval(60) }
+ private var later: Date { cutoff.addingTimeInterval(120) }
+
+ @Test("A peer's fill after the cutoff is included, attributed to the peer")
+ func peerFillIncluded() {
+ let moves = [view(author: "bob", cells: [(1, 2, "A", after)])]
+ let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
+ #expect(changes == [GridPosition(row: 1, col: 2): "bob"])
+ }
+
+ @Test("My own change is excluded even though it is newer than the cutoff")
+ func ownChangeExcluded() {
+ let moves = [view(author: "alice", cells: [(0, 0, "X", after)])]
+ let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
+ #expect(changes.isEmpty)
+ }
+
+ @Test("A peer's clear after the cutoff is included and attributed to the peer")
+ func peerClearIncluded() {
+ // Carol fills the cell, then Bob clears it later — the clear is the
+ // winning move, so the cell is attributed to Bob despite carrying no
+ // preserved letter author.
+ let moves = [
+ view(author: "carol", cells: [(3, 4, "Q", before)]),
+ view(author: "bob", cells: [(3, 4, "", later)]),
+ ]
+ let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
+ #expect(changes == [GridPosition(row: 3, col: 4): "bob"])
+ }
+
+ @Test("A change at or before the cutoff is excluded")
+ func preCutoffExcluded() {
+ let moves = [
+ view(author: "bob", cells: [(0, 0, "A", before)]),
+ view(author: "carol", cells: [(1, 1, "B", cutoff)]),
+ ]
+ let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
+ #expect(changes.isEmpty)
+ }
+
+ @Test("Later peer write wins the cell's attribution over an earlier one")
+ func latestWriterWins() {
+ let moves = [
+ view(author: "bob", cells: [(2, 2, "A", after)]),
+ view(author: "carol", cells: [(2, 2, "B", later)]),
+ ]
+ let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
+ #expect(changes == [GridPosition(row: 2, col: 2): "carol"])
+ }
+
+ @Test("Empty moves produce no changes")
+ func emptyMoves() {
+ let changes = RecentChanges.changedCells(in: [], since: cutoff, excludingAuthor: "alice")
+ #expect(changes.isEmpty)
+ }
+}