From f8ed0e7d1cd0cb648ef5041fdd722ef60feae74c Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 10:48:04 -0800 Subject: [PATCH 1/9] Add Highlights section to stats view Add Highlights section to stats view and break out stats view into dedicated file, instead of being intermixed with settings --- ComputerSolitaire/Views/ContentView.swift | 4 +- ComputerSolitaire/Views/SettingsView.swift | 83 ------------ ComputerSolitaire/Views/StatisticsView.swift | 126 +++++++++++++++++++ 3 files changed, 128 insertions(+), 85 deletions(-) create mode 100644 ComputerSolitaire/Views/StatisticsView.swift diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/ContentView.swift index f6442b1..1235e3b 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/ContentView.swift @@ -296,10 +296,10 @@ struct ContentView: View { .sheet(isPresented: $isShowingStats) { #if os(iOS) NavigationStack { - StatsView(viewModel: viewModel) + StatisticsView(viewModel: viewModel) } #else - StatsView(viewModel: viewModel) + StatisticsView(viewModel: viewModel) #endif } ) diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 65d63ec..b774595 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -235,89 +235,6 @@ private struct SettingsCard: View { } } -struct StatsView: View { - let viewModel: SolitaireViewModel? - @Environment(\.dismiss) private var dismiss - @State private var stats = GameStatistics() - private let durationFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.day, .hour, .minute, .second] - formatter.unitsStyle = .abbreviated - formatter.zeroFormattingBehavior = .dropLeading - return formatter - }() - - var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - Form { - Section("Games") { - keyValueRow("Games Played", "\(stats.gamesPlayed)") - keyValueRow("Games Won", "\(stats.gamesWon)") - keyValueRow("Win Rate", winRateLabel) - } - - Section("Performance") { - keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) - keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) - keyValueRow("Best Time", bestTimeLabel) - keyValueRow("High Score (3-card)", "\(stats.highScoreDrawThree)") - keyValueRow("High Score (1-card)", "\(stats.highScoreDrawOne)") - } - } - } - .navigationTitle("Statistics") -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) - .frame(minWidth: 420, minHeight: 320) -#endif - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - .keyboardShortcut(.cancelAction) - } - } - .onAppear { - stats = GameStatisticsStore.load() - } - } - - private var winRateLabel: String { - String(format: "%.1f%%", stats.winRate * 100) - } - - private var bestTimeLabel: String { - guard let bestTimeSeconds = stats.bestTimeSeconds else { return "-" } - return durationLabel(bestTimeSeconds) - } - - private func displayTotalTimeSeconds(at date: Date) -> Int { - let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 - let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) - return overflow ? Int.max : max(0, sum) - } - - @ViewBuilder - private func keyValueRow(_ key: String, _ value: String) -> some View { - HStack { - Text(key) - Spacer(minLength: 16) - Text(value) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - - private func durationLabel(_ seconds: Int) -> String { - let total = max(0, seconds) - return durationFormatter.string(from: TimeInterval(total)) ?? "0s" - } -} - #Preview { SettingsView() } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift new file mode 100644 index 0000000..e3f723e --- /dev/null +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -0,0 +1,126 @@ +import Foundation +import SwiftUI + +struct StatisticsView: View { + let viewModel: SolitaireViewModel? + @Environment(\.dismiss) private var dismiss + @State private var stats = GameStatistics() + private let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute, .second] + formatter.unitsStyle = .abbreviated + formatter.zeroFormattingBehavior = .dropLeading + return formatter + }() + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + Form { + Section { + HStack(spacing: 0) { + highlightCard( + icon: "percent", + label: "Win Rate", + value: winRateLabel + ) + Divider() + .frame(height: 32) + highlightCard( + icon: "timer", + label: "Best Time", + value: bestTimeLabel + ) + Divider() + .frame(height: 32) + highlightCard( + icon: "trophy.fill", + label: "Games Won", + value: "\(stats.gamesWon)" + ) + } + .padding(.vertical, 2) + } header: { + Text("Highlights") + } + + Section("Games") { + keyValueRow("Games Played", "\(stats.gamesPlayed)") + keyValueRow("Games Won", "\(stats.gamesWon)") + keyValueRow("Win Rate", winRateLabel) + } + + Section("Performance") { + keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) + keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) + keyValueRow("Best Time", bestTimeLabel) + keyValueRow("High Score (3-card)", "\(stats.highScoreDrawThree)") + keyValueRow("High Score (1-card)", "\(stats.highScoreDrawOne)") + } + } + } + .navigationTitle("Statistics") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#else + .formStyle(.grouped) + .padding(16) + .frame(minWidth: 420, minHeight: 320) +#endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + } + .onAppear { + stats = GameStatisticsStore.load() + } + } + + private var winRateLabel: String { + String(format: "%.1f%%", stats.winRate * 100) + } + + private var bestTimeLabel: String { + guard let bestTimeSeconds = stats.bestTimeSeconds else { return "-" } + return durationLabel(bestTimeSeconds) + } + + private func displayTotalTimeSeconds(at date: Date) -> Int { + let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 + let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) + return overflow ? Int.max : max(0, sum) + } + + private func highlightCard(icon: String, label: String, value: String) -> some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.subheadline, design: .rounded, weight: .bold)) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func keyValueRow(_ key: String, _ value: String) -> some View { + HStack { + Text(key) + Spacer(minLength: 16) + Text(value) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + private func durationLabel(_ seconds: Int) -> String { + let total = max(0, seconds) + return durationFormatter.string(from: TimeInterval(total)) ?? "0s" + } +} From 7c777bf8ac9208535f928aa3a424992ca0906c3c Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 11:13:49 -0800 Subject: [PATCH 2/9] Add win/loss bar with hover tooltip Render a visual win/loss bar in StatisticsView and show counts on hover. Adds a barHoverState @State, a winLossBar view that computes win/loss widths via GeometryReader, and a reusable barSegment helper that updates hover state with onContinuousHover. Integrates the bar below the Win Rate row when games have been played to give a quick visual summary of wins vs losses. --- ComputerSolitaire/Views/StatisticsView.swift | 53 +++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index e3f723e..48600f9 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -5,6 +5,7 @@ struct StatisticsView: View { let viewModel: SolitaireViewModel? @Environment(\.dismiss) private var dismiss @State private var stats = GameStatistics() + @State private var barHoverState: (label: String, x: CGFloat)? private let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.day, .hour, .minute, .second] @@ -46,7 +47,12 @@ struct StatisticsView: View { Section("Games") { keyValueRow("Games Played", "\(stats.gamesPlayed)") keyValueRow("Games Won", "\(stats.gamesWon)") - keyValueRow("Win Rate", winRateLabel) + VStack(spacing: 6) { + keyValueRow("Win Rate", winRateLabel) + if stats.gamesPlayed > 0 { + winLossBar + } + } } Section("Performance") { @@ -79,6 +85,51 @@ struct StatisticsView: View { } } + private var winLossBar: some View { + let losses = stats.gamesPlayed - stats.gamesWon + let winsLabel = "\(stats.gamesWon) \(stats.gamesWon == 1 ? "win" : "wins")" + let lossesLabel = "\(losses) \(losses == 1 ? "loss" : "losses")" + return GeometryReader { geo in + let winFraction = CGFloat(stats.gamesWon) / CGFloat(max(1, stats.gamesPlayed)) + let winWidth = max(winFraction > 0 ? 4 : 0, geo.size.width * winFraction - 1) + let lossWidth = max(winFraction < 1 ? 4 : 0, geo.size.width * (1 - winFraction) - 1) + HStack(spacing: 2) { + barSegment(fill: .green.opacity(0.6), width: winWidth, label: winsLabel, xOffset: 0) + barSegment(fill: .red.opacity(0.35), width: lossWidth, label: lossesLabel, xOffset: winWidth + 2) + } + .overlay(alignment: .topLeading) { + if let hover = barHoverState { + Text(hover.label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .fixedSize() + .position(x: hover.x, y: -16) + .allowsHitTesting(false) + } + } + } + .frame(height: 8) + .padding(.top, 4) + } + + private func barSegment(fill: some ShapeStyle, width: CGFloat, label: String, xOffset: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(fill) + .frame(width: width) + .onContinuousHover { phase in + switch phase { + case .active(let location): + barHoverState = (label: label, x: xOffset + location.x) + case .ended: + barHoverState = nil + } + } + } + private var winRateLabel: String { String(format: "%.1f%%", stats.winRate * 100) } From cc4760849d311043659c869b07d4ac20c4ced03b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 12:58:16 -0800 Subject: [PATCH 3/9] Use monospaced headline font for stats value Replace the stats value font in StatisticsView.swift from a rounded subheadline to a monospaced headline with bold weight. This increases prominence and improves alignment/readability of numeric values in the statistics UI. --- ComputerSolitaire/Views/StatisticsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 48600f9..ff98222 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -151,7 +151,7 @@ struct StatisticsView: View { .font(.subheadline) .foregroundStyle(.secondary) Text(value) - .font(.system(.subheadline, design: .rounded, weight: .bold)) + .font(.system(.headline, design: .monospaced, weight: .bold)) Text(label) .font(.caption2) .foregroundStyle(.secondary) From 9ada428e5d1cc1f9197b49c8f01370f183d69295 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 13:12:42 -0800 Subject: [PATCH 4/9] Make high score fields optional and handle nil Change GameStatistics.highScoreDrawThree and highScoreDrawOne from Int to Int? to represent unset scores. Update initializer and sanitization to use optional mapping, and use nil-coalescing when computing new highs. Update StatisticsView to display "-" when a high score is nil instead of printing 0, making it clear when no score is set. --- ComputerSolitaire/Game/GamePersistence.swift | 16 ++++++++-------- ComputerSolitaire/Views/StatisticsView.swift | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ComputerSolitaire/Game/GamePersistence.swift b/ComputerSolitaire/Game/GamePersistence.swift index 950b871..b29f9c9 100644 --- a/ComputerSolitaire/Game/GamePersistence.swift +++ b/ComputerSolitaire/Game/GamePersistence.swift @@ -228,8 +228,8 @@ struct GameStatistics: Codable, Equatable { var gamesWon: Int var totalTimeSeconds: Int var bestTimeSeconds: Int? - var highScoreDrawThree: Int - var highScoreDrawOne: Int + var highScoreDrawThree: Int? + var highScoreDrawOne: Int? init( schemaVersion: Int = currentSchemaVersion, @@ -237,16 +237,16 @@ struct GameStatistics: Codable, Equatable { gamesWon: Int = 0, totalTimeSeconds: Int = 0, bestTimeSeconds: Int? = nil, - highScoreDrawThree: Int = 0, - highScoreDrawOne: Int = 0 + highScoreDrawThree: Int? = nil, + highScoreDrawOne: Int? = nil ) { self.schemaVersion = schemaVersion self.gamesPlayed = max(0, gamesPlayed) self.gamesWon = max(0, min(gamesWon, gamesPlayed)) self.totalTimeSeconds = max(0, totalTimeSeconds) self.bestTimeSeconds = bestTimeSeconds.map { max(0, $0) } - self.highScoreDrawThree = max(0, highScoreDrawThree) - self.highScoreDrawOne = max(0, highScoreDrawOne) + self.highScoreDrawThree = highScoreDrawThree.map { max(0, $0) } + self.highScoreDrawOne = highScoreDrawOne.map { max(0, $0) } } var winRate: Double { @@ -281,9 +281,9 @@ struct GameStatistics: Codable, Equatable { } if drawCount == DrawMode.one.rawValue { - highScoreDrawOne = max(highScoreDrawOne, sanitizedScore) + highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) } else { - highScoreDrawThree = max(highScoreDrawThree, sanitizedScore) + highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) } } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index ff98222..0226dc9 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -59,8 +59,8 @@ struct StatisticsView: View { keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) keyValueRow("Best Time", bestTimeLabel) - keyValueRow("High Score (3-card)", "\(stats.highScoreDrawThree)") - keyValueRow("High Score (1-card)", "\(stats.highScoreDrawOne)") + keyValueRow("High Score (3-card)", stats.highScoreDrawThree.map { "\($0)" } ?? "-") + keyValueRow("High Score (1-card)", stats.highScoreDrawOne.map { "\($0)" } ?? "-") } } } From 4e31b18187f56951fdebfad571079918cf5e806b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 14:23:43 -0800 Subject: [PATCH 5/9] Track and display clean wins stat Add tracking for hint requests, undos and redeal usage across a game and persist those metrics in SavedGamePayload. Introduce cleanWins to GameStatistics with decoding/sanitization, a cleanWinRate, and update recordCompletedGame to accept hints/undos/redeal and increment cleanWins for wins with no hints, undos or redeals. Wire up SolitaireViewModel to record/reset these counters, include them in saved/restored payloads, and pass them when finalizing a game. Update StatisticsView to show a "Clean Wins" row with an info popover and formatting. Add unit tests (GameStatisticsCleanWinTests) covering clean-win behavior and legacy decoding. --- ComputerSolitaire/Game/GamePersistence.swift | 95 ++++++++++++++- ComputerSolitaire/Game/GameSession.swift | 24 +++- ComputerSolitaire/Views/StatisticsView.swift | 43 ++++++- .../GameStatisticsCleanWinTests.swift | 114 ++++++++++++++++++ 4 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 ComputerSolitaireTests/GameStatisticsCleanWinTests.swift diff --git a/ComputerSolitaire/Game/GamePersistence.swift b/ComputerSolitaire/Game/GamePersistence.swift index b29f9c9..8d66941 100644 --- a/ComputerSolitaire/Game/GamePersistence.swift +++ b/ComputerSolitaire/Game/GamePersistence.swift @@ -38,6 +38,9 @@ struct SavedGamePayload: Codable { let redealState: GameState? let hasStartedTrackedGame: Bool let isCurrentGameFinalized: Bool + let hintRequestsInCurrentGame: Int + let undosUsedInCurrentGame: Int + let usedRedealInCurrentGame: Bool enum CodingKeys: String, CodingKey { case schemaVersion @@ -55,6 +58,9 @@ struct SavedGamePayload: Codable { case redealState case hasStartedTrackedGame case isCurrentGameFinalized + case hintRequestsInCurrentGame + case undosUsedInCurrentGame + case usedRedealInCurrentGame } init( @@ -72,7 +78,10 @@ struct SavedGamePayload: Codable { history: [GameSnapshot], redealState: GameState? = nil, hasStartedTrackedGame: Bool = true, - isCurrentGameFinalized: Bool = false + isCurrentGameFinalized: Bool = false, + hintRequestsInCurrentGame: Int = 0, + undosUsedInCurrentGame: Int = 0, + usedRedealInCurrentGame: Bool = false ) { self.schemaVersion = schemaVersion self.savedAt = savedAt @@ -89,6 +98,9 @@ struct SavedGamePayload: Codable { self.redealState = redealState self.hasStartedTrackedGame = hasStartedTrackedGame self.isCurrentGameFinalized = isCurrentGameFinalized + self.hintRequestsInCurrentGame = max(0, hintRequestsInCurrentGame) + self.undosUsedInCurrentGame = max(0, undosUsedInCurrentGame) + self.usedRedealInCurrentGame = usedRedealInCurrentGame } init(from decoder: Decoder) throws { @@ -108,6 +120,15 @@ struct SavedGamePayload: Codable { redealState = try container.decodeIfPresent(GameState.self, forKey: .redealState) hasStartedTrackedGame = try container.decodeIfPresent(Bool.self, forKey: .hasStartedTrackedGame) ?? true isCurrentGameFinalized = try container.decodeIfPresent(Bool.self, forKey: .isCurrentGameFinalized) ?? false + hintRequestsInCurrentGame = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .hintRequestsInCurrentGame) ?? 0 + ) + undosUsedInCurrentGame = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .undosUsedInCurrentGame) ?? 0 + ) + usedRedealInCurrentGame = try container.decodeIfPresent(Bool.self, forKey: .usedRedealInCurrentGame) ?? false } func sanitizedForRestore() -> SavedGamePayload? { @@ -129,6 +150,9 @@ struct SavedGamePayload: Codable { }() let sanitizedHasStartedTrackedGame = hasStartedTrackedGame let sanitizedIsCurrentGameFinalized = sanitizedHasStartedTrackedGame ? isCurrentGameFinalized : false + let sanitizedHintRequestsInCurrentGame = sanitizedHasStartedTrackedGame ? max(0, hintRequestsInCurrentGame) : 0 + let sanitizedUndosUsedInCurrentGame = sanitizedHasStartedTrackedGame ? max(0, undosUsedInCurrentGame) : 0 + let sanitizedUsedRedealInCurrentGame = sanitizedHasStartedTrackedGame ? usedRedealInCurrentGame : false let sanitizedHistory = history .filter { $0.movesCount >= 0 && $0.state.isValidForPersistence } .map { snapshot in @@ -172,7 +196,10 @@ struct SavedGamePayload: Codable { history: Array(sanitizedHistory), redealState: sanitizedRedealState, hasStartedTrackedGame: sanitizedHasStartedTrackedGame, - isCurrentGameFinalized: sanitizedIsCurrentGameFinalized + isCurrentGameFinalized: sanitizedIsCurrentGameFinalized, + hintRequestsInCurrentGame: sanitizedHintRequestsInCurrentGame, + undosUsedInCurrentGame: sanitizedUndosUsedInCurrentGame, + usedRedealInCurrentGame: sanitizedUsedRedealInCurrentGame ) } } @@ -230,6 +257,18 @@ struct GameStatistics: Codable, Equatable { var bestTimeSeconds: Int? var highScoreDrawThree: Int? var highScoreDrawOne: Int? + var cleanWins: Int + + enum CodingKeys: String, CodingKey { + case schemaVersion + case gamesPlayed + case gamesWon + case totalTimeSeconds + case bestTimeSeconds + case highScoreDrawThree + case highScoreDrawOne + case cleanWins + } init( schemaVersion: Int = currentSchemaVersion, @@ -238,7 +277,8 @@ struct GameStatistics: Codable, Equatable { totalTimeSeconds: Int = 0, bestTimeSeconds: Int? = nil, highScoreDrawThree: Int? = nil, - highScoreDrawOne: Int? = nil + highScoreDrawOne: Int? = nil, + cleanWins: Int = 0 ) { self.schemaVersion = schemaVersion self.gamesPlayed = max(0, gamesPlayed) @@ -247,6 +287,36 @@ struct GameStatistics: Codable, Equatable { self.bestTimeSeconds = bestTimeSeconds.map { max(0, $0) } self.highScoreDrawThree = highScoreDrawThree.map { max(0, $0) } self.highScoreDrawOne = highScoreDrawOne.map { max(0, $0) } + self.cleanWins = max(0, min(cleanWins, self.gamesWon)) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let decodedSchemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? Self.currentSchemaVersion + let decodedGamesPlayed = max(0, try container.decodeIfPresent(Int.self, forKey: .gamesPlayed) ?? 0) + let decodedGamesWon = max( + 0, + min( + try container.decodeIfPresent(Int.self, forKey: .gamesWon) ?? 0, + decodedGamesPlayed + ) + ) + + schemaVersion = decodedSchemaVersion + gamesPlayed = decodedGamesPlayed + gamesWon = decodedGamesWon + totalTimeSeconds = max(0, try container.decodeIfPresent(Int.self, forKey: .totalTimeSeconds) ?? 0) + bestTimeSeconds = try container.decodeIfPresent(Int.self, forKey: .bestTimeSeconds).map { max(0, $0) } + highScoreDrawThree = try container.decodeIfPresent(Int.self, forKey: .highScoreDrawThree).map { max(0, $0) } + highScoreDrawOne = try container.decodeIfPresent(Int.self, forKey: .highScoreDrawOne).map { max(0, $0) } + cleanWins = max( + 0, + min( + try container.decodeIfPresent(Int.self, forKey: .cleanWins) ?? 0, + decodedGamesWon + ) + ) } var winRate: Double { @@ -259,14 +329,24 @@ struct GameStatistics: Codable, Equatable { return totalTimeSeconds / gamesPlayed } + var cleanWinRate: Double { + guard gamesWon > 0 else { return 0 } + return Double(cleanWins) / Double(gamesWon) + } + mutating func recordCompletedGame( didWin: Bool, elapsedSeconds: Int, finalScore: Int, - drawCount: Int + drawCount: Int, + hintsUsedInGame: Int, + undosUsedInGame: Int, + usedRedealInGame: Bool ) { let sanitizedElapsed = max(0, elapsedSeconds) let sanitizedScore = max(0, finalScore) + let sanitizedHintsUsedInGame = max(0, hintsUsedInGame) + let sanitizedUndosUsedInGame = max(0, undosUsedInGame) gamesPlayed = addingSafely(gamesPlayed, 1) totalTimeSeconds = addingSafely(totalTimeSeconds, sanitizedElapsed) @@ -285,6 +365,13 @@ struct GameStatistics: Codable, Equatable { } else { highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) } + + let isCleanWin = sanitizedHintsUsedInGame == 0 + && sanitizedUndosUsedInGame == 0 + && !usedRedealInGame + if isCleanWin { + cleanWins = min(gamesWon, addingSafely(cleanWins, 1)) + } } private func addingSafely(_ lhs: Int, _ rhs: Int) -> Int { diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index 6dc989e..a74e2db 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -31,6 +31,9 @@ final class SolitaireViewModel { private var scoringDrawCount: Int = DrawMode.three.rawValue private var hasStartedTrackedGame = false private var isCurrentGameFinalized = false + private var hintRequestsInCurrentGame: Int = 0 + private var undosUsedInCurrentGame: Int = 0 + private var usedRedealInCurrentGame = false private var history: [GameSnapshot] = [] @@ -112,6 +115,7 @@ final class SolitaireViewModel { activeHint = hint hintWiggleToken = UUID() scheduleHintAutoClear(for: hint) + hintRequestsInCurrentGame += 1 HapticManager.shared.play(.settingsSelection) } @@ -193,6 +197,9 @@ final class SolitaireViewModel { scoringDrawCount = drawMode.rawValue hasStartedTrackedGame = true isCurrentGameFinalized = false + hintRequestsInCurrentGame = 0 + undosUsedInCurrentGame = 0 + usedRedealInCurrentGame = false state.wasteDrawCount = 0 history.removeAll() refreshAutoFinishAvailability() @@ -214,6 +221,9 @@ final class SolitaireViewModel { scoringDrawCount = stockDrawCount hasStartedTrackedGame = true isCurrentGameFinalized = false + hintRequestsInCurrentGame = 0 + undosUsedInCurrentGame = 0 + usedRedealInCurrentGame = true state.wasteDrawCount = min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) history.removeAll() refreshAutoFinishAvailability() @@ -247,6 +257,7 @@ final class SolitaireViewModel { score = snapshot.score hasAppliedTimeBonus = snapshot.hasAppliedTimeBonus finalElapsedSeconds = nil + undosUsedInCurrentGame += 1 selection = nil isDragging = false pendingAutoMove = nil @@ -272,7 +283,10 @@ final class SolitaireViewModel { history: history, redealState: redealState, hasStartedTrackedGame: hasStartedTrackedGame, - isCurrentGameFinalized: isCurrentGameFinalized + isCurrentGameFinalized: isCurrentGameFinalized, + hintRequestsInCurrentGame: hintRequestsInCurrentGame, + undosUsedInCurrentGame: undosUsedInCurrentGame, + usedRedealInCurrentGame: usedRedealInCurrentGame ) } @@ -295,6 +309,9 @@ final class SolitaireViewModel { scoringDrawCount = sanitizedPayload.scoringDrawCount hasStartedTrackedGame = sanitizedPayload.hasStartedTrackedGame isCurrentGameFinalized = sanitizedPayload.isCurrentGameFinalized + hintRequestsInCurrentGame = sanitizedPayload.hintRequestsInCurrentGame + undosUsedInCurrentGame = sanitizedPayload.undosUsedInCurrentGame + usedRedealInCurrentGame = sanitizedPayload.usedRedealInCurrentGame history = Array(sanitizedPayload.history.suffix(Self.maxUndoHistoryCount)) var restoredRedealState = sanitizedPayload.redealState ?? history.first?.state ?? state restoredRedealState.wasteDrawCount = min( @@ -708,7 +725,10 @@ private extension SolitaireViewModel { didWin: didWin, elapsedSeconds: elapsedSeconds, finalScore: score, - drawCount: scoringDrawCount + drawCount: scoringDrawCount, + hintsUsedInGame: hintRequestsInCurrentGame, + undosUsedInGame: undosUsedInCurrentGame, + usedRedealInGame: usedRedealInCurrentGame ) } isCurrentGameFinalized = true diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 0226dc9..bb5989d 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -6,6 +6,7 @@ struct StatisticsView: View { @Environment(\.dismiss) private var dismiss @State private var stats = GameStatistics() @State private var barHoverState: (label: String, x: CGFloat)? + @State private var isShowingCleanWinsInfo = false private let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.day, .hour, .minute, .second] @@ -46,13 +47,14 @@ struct StatisticsView: View { Section("Games") { keyValueRow("Games Played", "\(stats.gamesPlayed)") - keyValueRow("Games Won", "\(stats.gamesWon)") + keyValueRow("Wins", "\(stats.gamesWon)") VStack(spacing: 6) { keyValueRow("Win Rate", winRateLabel) if stats.gamesPlayed > 0 { winLossBar } } + cleanWinsRow } Section("Performance") { @@ -113,7 +115,7 @@ struct StatisticsView: View { } } .frame(height: 8) - .padding(.top, 4) + .padding(.top, 2) } private func barSegment(fill: some ShapeStyle, width: CGFloat, label: String, xOffset: CGFloat) -> some View { @@ -139,6 +141,10 @@ struct StatisticsView: View { return durationLabel(bestTimeSeconds) } + private var cleanWinRateLabel: String { + return String(format: "%.1f%%", stats.cleanWinRate * 100) + } + private func displayTotalTimeSeconds(at date: Date) -> Int { let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) @@ -170,6 +176,39 @@ struct StatisticsView: View { } } + private var cleanWinsRow: some View { + HStack(spacing: 4) { + Text("Clean Wins") + Button { + isShowingCleanWinsInfo = true + } label: { + Image(systemName: "info.circle") + .font(.footnote) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clean Wins info") + .popover(isPresented: $isShowingCleanWinsInfo, arrowEdge: .top) { + Text("Wins completed without the use of hints, undos, or redeals.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 280, alignment: .leading) + .padding(12) +#if os(iOS) + .presentationCompactAdaptation(.popover) +#endif + } + Spacer(minLength: 12) + Text("\(stats.cleanWins) (\(cleanWinRateLabel))") + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 2) + .padding(.leading, 2) + } + private func durationLabel(_ seconds: Int) -> String { let total = max(0, seconds) return durationFormatter.string(from: TimeInterval(total)) ?? "0s" diff --git a/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift b/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift new file mode 100644 index 0000000..4e2d80b --- /dev/null +++ b/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameStatisticsCleanWinTests: XCTestCase { + func testCleanWinIncrementsWhenNoHintUndoOrRedealWasUsed() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 120, + finalScore: 200, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, 1) + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 1) + XCTAssertEqual(stats.cleanWinRate, 1.0, accuracy: 0.0001) + } + + func testWinWithHintIsNotCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 250, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 1, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testWinWithUndoIsNotCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 250, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 2, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testWinWithRedealIsNotCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 250, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: true + ) + + XCTAssertEqual(stats.gamesWon, 1) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testLossNeverCountsAsCleanWin() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: false, + elapsedSeconds: 300, + finalScore: 150, + drawCount: DrawMode.one.rawValue, + hintsUsedInGame: 2, + undosUsedInGame: 3, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, 1) + XCTAssertEqual(stats.gamesWon, 0) + XCTAssertEqual(stats.cleanWins, 0) + } + + func testDecodingLegacyStatsDefaultsCleanWinsToZero() throws { + let legacyJSON = """ + { + "schemaVersion": 1, + "gamesPlayed": 8, + "gamesWon": 3, + "totalTimeSeconds": 1200, + "bestTimeSeconds": 140, + "highScoreDrawThree": 780, + "highScoreDrawOne": 620 + } + """ + + let data = try XCTUnwrap(legacyJSON.data(using: .utf8)) + let decoded = try JSONDecoder().decode(GameStatistics.self, from: data) + + XCTAssertEqual(decoded.cleanWins, 0) + XCTAssertEqual(decoded.gamesPlayed, 8) + XCTAssertEqual(decoded.gamesWon, 3) + } +} From c5bdc588a612f825040afc1d44110ca25836965f Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 19:29:37 -0800 Subject: [PATCH 6/9] Add stats reset ability Introduce an optional trackedSince Date to GameStatistics (with CodingKey, init, decoding) to record when stats tracking began. Add helper methods markTrackingStarted(at:) and reset(at:) and expose store APIs (GameStatisticsStore.markTrackingStarted / reset). Update SolitaireViewModel to mark tracking started when a new session is created. Enhance StatisticsView with a Reset Stats button, confirmation dialog, trackedSince label, and resetStatistics() handler to clear stats. These changes allow tracking the statistics start date and provide a safe UI flow to reset statistics. --- ComputerSolitaire/Game/GamePersistence.swift | 31 ++++++++++++++ ComputerSolitaire/Game/GameSession.swift | 4 ++ ComputerSolitaire/Views/StatisticsView.swift | 45 +++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/ComputerSolitaire/Game/GamePersistence.swift b/ComputerSolitaire/Game/GamePersistence.swift index 8d66941..e63a2b7 100644 --- a/ComputerSolitaire/Game/GamePersistence.swift +++ b/ComputerSolitaire/Game/GamePersistence.swift @@ -251,6 +251,7 @@ struct GameStatistics: Codable, Equatable { static let currentSchemaVersion = 1 let schemaVersion: Int + var trackedSince: Date? var gamesPlayed: Int var gamesWon: Int var totalTimeSeconds: Int @@ -261,6 +262,7 @@ struct GameStatistics: Codable, Equatable { enum CodingKeys: String, CodingKey { case schemaVersion + case trackedSince case gamesPlayed case gamesWon case totalTimeSeconds @@ -272,6 +274,7 @@ struct GameStatistics: Codable, Equatable { init( schemaVersion: Int = currentSchemaVersion, + trackedSince: Date? = nil, gamesPlayed: Int = 0, gamesWon: Int = 0, totalTimeSeconds: Int = 0, @@ -281,6 +284,7 @@ struct GameStatistics: Codable, Equatable { cleanWins: Int = 0 ) { self.schemaVersion = schemaVersion + self.trackedSince = trackedSince self.gamesPlayed = max(0, gamesPlayed) self.gamesWon = max(0, min(gamesWon, gamesPlayed)) self.totalTimeSeconds = max(0, totalTimeSeconds) @@ -304,6 +308,7 @@ struct GameStatistics: Codable, Equatable { ) schemaVersion = decodedSchemaVersion + trackedSince = try container.decodeIfPresent(Date.self, forKey: .trackedSince) gamesPlayed = decodedGamesPlayed gamesWon = decodedGamesWon totalTimeSeconds = max(0, try container.decodeIfPresent(Int.self, forKey: .totalTimeSeconds) ?? 0) @@ -374,6 +379,16 @@ struct GameStatistics: Codable, Equatable { } } + mutating func markTrackingStarted(at date: Date = .now) { + if trackedSince == nil { + trackedSince = date + } + } + + mutating func reset(at date: Date = .now) { + self = GameStatistics(trackedSince: date) + } + private func addingSafely(_ lhs: Int, _ rhs: Int) -> Int { let (sum, overflow) = lhs.addingReportingOverflow(rhs) return overflow ? Int.max : sum @@ -405,6 +420,22 @@ enum GameStatisticsStore { mutate(&stats) save(stats, userDefaults: userDefaults) } + + static func markTrackingStarted( + userDefaults: UserDefaults = .standard, + at date: Date = .now + ) { + update(userDefaults: userDefaults) { stats in + stats.markTrackingStarted(at: date) + } + } + + static func reset( + userDefaults: UserDefaults = .standard, + at date: Date = .now + ) { + save(GameStatistics(trackedSince: date), userDefaults: userDefaults) + } } private struct CardIdentity: Hashable { diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index a74e2db..8a13f6c 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -44,6 +44,7 @@ final class SolitaireViewModel { } init() { + let startedAt = Date() let initialState = GameState.newGame() state = initialState isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: initialState) @@ -52,6 +53,9 @@ final class SolitaireViewModel { stockDrawCount: DrawMode.three.rawValue ) != nil redealState = initialState + gameStartedAt = startedAt + hasStartedTrackedGame = true + GameStatisticsStore.markTrackingStarted(at: startedAt) } var isWin: Bool { diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index bb5989d..b2de100 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -7,6 +7,7 @@ struct StatisticsView: View { @State private var stats = GameStatistics() @State private var barHoverState: (label: String, x: CGFloat)? @State private var isShowingCleanWinsInfo = false + @State private var isShowingResetConfirmation = false private let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.day, .hour, .minute, .second] @@ -45,7 +46,7 @@ struct StatisticsView: View { Text("Highlights") } - Section("Games") { + Section { keyValueRow("Games Played", "\(stats.gamesPlayed)") keyValueRow("Wins", "\(stats.gamesWon)") VStack(spacing: 6) { @@ -55,14 +56,18 @@ struct StatisticsView: View { } } cleanWinsRow + } header: { + Text("Games") } - Section("Performance") { + Section { keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) keyValueRow("Best Time", bestTimeLabel) keyValueRow("High Score (3-card)", stats.highScoreDrawThree.map { "\($0)" } ?? "-") keyValueRow("High Score (1-card)", stats.highScoreDrawOne.map { "\($0)" } ?? "-") + } header: { + Text("Performance") } } } @@ -75,6 +80,19 @@ struct StatisticsView: View { .frame(minWidth: 420, minHeight: 320) #endif .toolbar { +#if os(macOS) + ToolbarItem(placement: .automatic) { + Button("Reset Stats") { + isShowingResetConfirmation = true + } + } +#else + ToolbarItem(placement: .topBarLeading) { + Button("Reset Stats") { + isShowingResetConfirmation = true + } + } +#endif ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() @@ -85,6 +103,18 @@ struct StatisticsView: View { .onAppear { stats = GameStatisticsStore.load() } + .confirmationDialog( + "Reset statistics?", + isPresented: $isShowingResetConfirmation, + titleVisibility: .visible + ) { + Button("Reset Statistics", role: .destructive) { + resetStatistics() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("All games, times, win rates, and high scores will be reset.") + } } private var winLossBar: some View { @@ -145,6 +175,11 @@ struct StatisticsView: View { return String(format: "%.1f%%", stats.cleanWinRate * 100) } + private var trackedSinceLabel: String { + guard let trackedSince = stats.trackedSince else { return "-" } + return trackedSince.formatted(date: .abbreviated, time: .omitted) + } + private func displayTotalTimeSeconds(at date: Date) -> Int { let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) @@ -213,4 +248,10 @@ struct StatisticsView: View { let total = max(0, seconds) return durationFormatter.string(from: TimeInterval(total)) ?? "0s" } + + private func resetStatistics() { + GameStatisticsStore.reset() + stats = GameStatisticsStore.load() + barHoverState = nil + } } From 88a39b7080352292d8047b25fc30a8d7fb439614 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 22 Feb 2026 20:41:11 -0800 Subject: [PATCH 7/9] Show "tracking since" and add session tests Fix tracking initialization by setting hasStartedTrackedGame to false so the bootstrap startup does not count as an active tracked game. This prevents the initial app launch from being immediately finalized as a played game while still recording the trackedSince timestamp. Add GameSessionTrackingTests.swift with comprehensive unit tests covering startup, first and second New Game behavior, redeal handling, and restore behavior for active/finalized/untracked saved payloads. Tests exercise GameStatisticsStore and unfinalized elapsed-time reporting to validate the game session tracking lifecycle. --- ComputerSolitaire/Game/GameSession.swift | 2 +- ComputerSolitaire/Views/StatisticsView.swift | 7 + .../GameSessionTrackingTests.swift | 167 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 ComputerSolitaireTests/GameSessionTrackingTests.swift diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index 8a13f6c..a9bf953 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -54,7 +54,7 @@ final class SolitaireViewModel { ) != nil redealState = initialState gameStartedAt = startedAt - hasStartedTrackedGame = true + hasStartedTrackedGame = false GameStatisticsStore.markTrackingStarted(at: startedAt) } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index b2de100..81267bb 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -69,6 +69,13 @@ struct StatisticsView: View { } header: { Text("Performance") } + + Text("Tracked since \(trackedSinceLabel)") + .font(.footnote) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } .navigationTitle("Statistics") diff --git a/ComputerSolitaireTests/GameSessionTrackingTests.swift b/ComputerSolitaireTests/GameSessionTrackingTests.swift new file mode 100644 index 0000000..de48c38 --- /dev/null +++ b/ComputerSolitaireTests/GameSessionTrackingTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameSessionTrackingTests: XCTestCase { + private let statsKey = GameStatisticsStore.defaultsKey + private static var retainedViewModels: [SolitaireViewModel] = [] + + // Verifies app startup initializes tracking metadata without starting a trackable game. + func testInitMarksTrackingStartWithoutActiveTrackedGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + let stats = GameStatisticsStore.load() + XCTAssertNotNil(stats.trackedSince) + XCTAssertEqual(stats.gamesPlayed, 0) + + let initialProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: initialProbeDate), 0) + } + } + + // Verifies the first explicit New Game starts tracking and does not finalize bootstrap state. + func testFirstNewGameStartsTrackingWithoutFinalizingBootstrapSession() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + + let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: trackedProbeDate), 0) + } + } + + // Verifies starting a second game finalizes exactly one previously tracked session. + func testSecondNewGameFinalizesExactlyOneTrackedGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 1) + } + } + + // Verifies redeal finalizes the current tracked session once and starts a fresh one. + func testRedealFinalizesCurrentTrackedGameExactlyOnce() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + viewModel.redeal() + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 1) + + let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: trackedProbeDate), 0) + } + } + + // Verifies restore resumes live elapsed reporting when payload is active and unfinalized. + func testRestoreWithActiveTrackedGameReportsLiveElapsed() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + hasStartedTrackedGame: true, + isCurrentGameFinalized: false + ) + + XCTAssertTrue(viewModel.restore(from: payload)) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) + } + } + + // Verifies finalized restored sessions are not finalized again when starting a new game. + func testRestoreWithFinalizedGameDoesNotFinalizeAgainOnNewGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + hasStartedTrackedGame: true, + isCurrentGameFinalized: true + ) + + XCTAssertTrue(viewModel.restore(from: payload)) + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) + + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + } + } + + // Verifies untracked restored sessions stay untracked until an explicit New Game starts tracking. + func testRestoreWithUntrackedPayloadRemainsUntrackedUntilNewGameStarts() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + let payload = makePayload( + hasStartedTrackedGame: false, + isCurrentGameFinalized: true + ) + + XCTAssertTrue(viewModel.restore(from: payload)) + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: .now), 0) + + viewModel.newGame(drawMode: .three) + + let stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + + let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: trackedProbeDate), 0) + } + } + + private func withIsolatedStatsStore(_ body: () -> Void) { + let defaults = UserDefaults.standard + let previousStatsData = defaults.data(forKey: statsKey) + defaults.removeObject(forKey: statsKey) + defer { + if let previousStatsData { + defaults.set(previousStatsData, forKey: statsKey) + } else { + defaults.removeObject(forKey: statsKey) + } + } + body() + } + + private func makeViewModel() -> SolitaireViewModel { + let viewModel = SolitaireViewModel() + Self.retainedViewModels.append(viewModel) + return viewModel + } + + private func makePayload( + hasStartedTrackedGame: Bool, + isCurrentGameFinalized: Bool + ) -> SavedGamePayload { + let state = GameState.newGame() + return SavedGamePayload( + savedAt: Date().addingTimeInterval(-300), + state: state, + movesCount: 0, + score: 0, + gameStartedAt: Date().addingTimeInterval(-600), + pauseStartedAt: nil, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: DrawMode.three.rawValue, + scoringDrawCount: DrawMode.three.rawValue, + history: [], + redealState: state, + hasStartedTrackedGame: hasStartedTrackedGame, + isCurrentGameFinalized: isCurrentGameFinalized, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } +} From 83dbfb6e9c716ddd62bfc9d0c7e351b3a7bc295b Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 22 Feb 2026 21:02:28 -0800 Subject: [PATCH 8/9] Reset game session tracking on stats reset Add resetStatisticsTracking() to SolitaireViewModel to clear in-memory tracking state (hasStartedTrackedGame, isCurrentGameFinalized, hintRequestsInCurrentGame, undosUsedInCurrentGame, usedRedealInCurrentGame). Invoke this from StatisticsView.resetStatistics so clearing persistent GameStatisticsStore also untracks the active session and prevents pre-reset progress from being counted. Add a unit test to verify that a reset untracks the current session until a new game starts and that gamesPlayed only increments after the next new game. --- ComputerSolitaire/Game/GameSession.swift | 8 +++++++ ComputerSolitaire/Views/StatisticsView.swift | 1 + .../GameSessionTrackingTests.swift | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index a9bf953..f30fcf3 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -144,6 +144,14 @@ final class SolitaireViewModel { return elapsedActiveSeconds(at: date) } + func resetStatisticsTracking() { + hasStartedTrackedGame = false + isCurrentGameFinalized = true + hintRequestsInCurrentGame = 0 + undosUsedInCurrentGame = 0 + usedRedealInCurrentGame = false + } + func displayScore(at date: Date = .now) -> Int { guard !hasAppliedTimeBonus else { return score } let elapsedSeconds = elapsedActiveSeconds(at: date) diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 81267bb..246235e 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -258,6 +258,7 @@ struct StatisticsView: View { private func resetStatistics() { GameStatisticsStore.reset() + viewModel?.resetStatisticsTracking() stats = GameStatisticsStore.load() barHoverState = nil } diff --git a/ComputerSolitaireTests/GameSessionTrackingTests.swift b/ComputerSolitaireTests/GameSessionTrackingTests.swift index de48c38..62e1275 100644 --- a/ComputerSolitaireTests/GameSessionTrackingTests.swift +++ b/ComputerSolitaireTests/GameSessionTrackingTests.swift @@ -119,6 +119,29 @@ final class GameSessionTrackingTests: XCTestCase { } } + // Verifies resetting statistics untracks the active session so pre-reset progress is not counted. + func testResetStatisticsUntracksCurrentSessionUntilNextNewGame() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(drawMode: .three) + let activeProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) + XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) + + GameStatisticsStore.reset() + viewModel.resetStatisticsTracking() + XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) + + viewModel.newGame(drawMode: .three) + var stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 0) + + viewModel.newGame(drawMode: .three) + stats = GameStatisticsStore.load() + XCTAssertEqual(stats.gamesPlayed, 1) + } + } + private func withIsolatedStatsStore(_ body: () -> Void) { let defaults = UserDefaults.standard let previousStatsData = defaults.data(forKey: statsKey) From 1aa1906e1c853bea5f887504be9ec44eb9228532 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 22 Feb 2026 21:13:13 -0800 Subject: [PATCH 9/9] Persist statistics reset to model context Add SwiftData modelContext to StatisticsView and persist the tracking-reset payload when statistics are reset. Introduces persistTrackingResetIfNeeded() which saves viewModel.persistencePayload() into the modelContext and logs errors in DEBUG builds. Updates tests to assert the reset payload reflects no started tracked game and a finalized current game. This ensures a statistics reset is stored so app state remains consistent across launches. --- ComputerSolitaire/Views/StatisticsView.swift | 14 ++++++++++++++ .../GameSessionTrackingTests.swift | 3 +++ 2 files changed, 17 insertions(+) diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 246235e..503daa9 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -1,8 +1,10 @@ import Foundation import SwiftUI +import SwiftData struct StatisticsView: View { let viewModel: SolitaireViewModel? + @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var stats = GameStatistics() @State private var barHoverState: (label: String, x: CGFloat)? @@ -259,7 +261,19 @@ struct StatisticsView: View { private func resetStatistics() { GameStatisticsStore.reset() viewModel?.resetStatisticsTracking() + persistTrackingResetIfNeeded() stats = GameStatisticsStore.load() barHoverState = nil } + + private func persistTrackingResetIfNeeded() { + guard let viewModel else { return } + do { + try GamePersistence.save(viewModel.persistencePayload(), in: modelContext) + } catch { +#if DEBUG + print("Failed to persist reset tracking state: \(error)") +#endif + } + } } diff --git a/ComputerSolitaireTests/GameSessionTrackingTests.swift b/ComputerSolitaireTests/GameSessionTrackingTests.swift index 62e1275..a9961d4 100644 --- a/ComputerSolitaireTests/GameSessionTrackingTests.swift +++ b/ComputerSolitaireTests/GameSessionTrackingTests.swift @@ -131,6 +131,9 @@ final class GameSessionTrackingTests: XCTestCase { GameStatisticsStore.reset() viewModel.resetStatisticsTracking() XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) + let resetPayload = viewModel.persistencePayload() + XCTAssertFalse(resetPayload.hasStartedTrackedGame) + XCTAssertTrue(resetPayload.isCurrentGameFinalized) viewModel.newGame(drawMode: .three) var stats = GameStatisticsStore.load()