diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index d351af4cb4..cb19affc93 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -3867,14 +3867,30 @@ struct TodayView: View { // MARK: (c) LAST WORKOUTS, SAME grid, uniform 104pt workout tiles. + /// Android's Today feed contract (`TodayScreen.recentCutoff`): sessions starting on or after the + /// start of the day 13 days back — 14 days counting today. Named rather than inlined so the window + /// is one thing on this platform too, and so the parity guard has something to point at. + static func recentWorkoutsFeed(_ rows: [WorkoutRow], now: Date = Date()) -> [WorkoutRow] { + let cal = Calendar.current + guard let cutoff = cal.date(byAdding: .day, value: -13, to: cal.startOfDay(for: now)) else { return rows } + let cutoffTs = Int(cutoff.timeIntervalSince1970) + return rows.filter { $0.startTs >= cutoffTs } + } + @ViewBuilder private var workoutsSection: some View { - if !workouts.isEmpty { + // #1702: window HERE, not on `workouts`. That array is shared — it also feeds the Data Sources + // Apple-workout count and the HR chart's sport glyphs, both all-time by design — so windowing it + // at the source would silently shrink two unrelated numbers on this same screen. + let recent = Self.recentWorkoutsFeed(workouts) + if !recent.isEmpty { VStack(alignment: .leading, spacing: NoopMetrics.gap) { + // "14 days" describes the window, like Android's today_workouts_14_days. The old + // "\(count) total" counted every workout ever recorded while showing at most six. SectionHeader("Latest Workouts", overline: "Activity", - trailing: String(localized: "\(workouts.count) total")) + trailing: String(localized: "14 days")) LazyVGrid(columns: grid, alignment: .leading, spacing: NoopMetrics.gap) { - ForEach(Array(workouts.prefix(6).enumerated()), id: \.offset) { _, w in + ForEach(Array(recent.prefix(6).enumerated()), id: \.offset) { _, w in Button { workoutDetail = WorkoutDetailTarget(row: w) } label: { diff --git a/StrandTests/TodayWorkoutsWindowTests.swift b/StrandTests/TodayWorkoutsWindowTests.swift new file mode 100644 index 0000000000..843f2e93b3 --- /dev/null +++ b/StrandTests/TodayWorkoutsWindowTests.swift @@ -0,0 +1,59 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// #1702: the Today "Latest Workouts" window, pinned to Android's contract. +/// +/// Android windows the feed to `today − 13` at start of day (`TodayScreen.recentCutoff`) and says so in +/// the header. iOS showed the same section from `repo.workoutRows()`, whose default is `days: 4000` — +/// effectively all-time — under a heading that says "Latest", with a trailing count of every workout +/// ever recorded. Same account, opposite readings on the two platforms. +/// +/// These pin the boundary rather than the happy path: an off-by-one here silently drops a session the +/// other platform shows, which is exactly the class of drift that produced the issue. +final class TodayWorkoutsWindowTests: XCTestCase { + + /// Fixed clock so "13 days back, start of day" is not evaluated against a moving now. + private let now = Date(timeIntervalSince1970: 1_756_000_000) // 2025-08-24T02:26:40Z + + /// WorkoutRow takes every field on purpose (#1444), so spell them all out. + private func row(startingAt ts: Int) -> WorkoutRow { + WorkoutRow(startTs: ts, endTs: ts + 1_800, sport: "Running", source: "whoop", + durationS: 1_800, energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: nil, steps: nil) + } + + private var cutoffTs: Int { + let cal = Calendar.current + return Int(cal.date(byAdding: .day, value: -13, to: cal.startOfDay(for: now))!.timeIntervalSince1970) + } + + func testKeepsASessionExactlyOnTheCutoff() { + let kept = TodayView.recentWorkoutsFeed([row(startingAt: cutoffTs)], now: now) + XCTAssertEqual(kept.count, 1, "the cutoff instant is inside the window, matching Android's >=") + } + + func testDropsTheSecondBeforeTheCutoff() { + let kept = TodayView.recentWorkoutsFeed([row(startingAt: cutoffTs - 1)], now: now) + XCTAssertTrue(kept.isEmpty) + } + + func testDropsAnAllTimeSessionThatUsedToShow() { + // The behaviour the issue described: a session from months ago listed under "Latest". + let threeMonthsAgo = Int(now.timeIntervalSince1970) - 90 * 86_400 + XCTAssertTrue(TodayView.recentWorkoutsFeed([row(startingAt: threeMonthsAgo)], now: now).isEmpty) + } + + func testKeepsTodaysSession() { + let kept = TodayView.recentWorkoutsFeed([row(startingAt: Int(now.timeIntervalSince1970))], now: now) + XCTAssertEqual(kept.count, 1) + } + + func testPreservesInputOrderAndDropsOnlyTheStale() { + let recentA = Int(now.timeIntervalSince1970) - 3_600 + let recentB = Int(now.timeIntervalSince1970) - 7_200 + let stale = cutoffTs - 86_400 + let kept = TodayView.recentWorkoutsFeed([row(startingAt: recentA), row(startingAt: stale), row(startingAt: recentB)], now: now) + XCTAssertEqual(kept.map(\.startTs), [recentA, recentB], "filtering must not reorder the feed") + } +} diff --git a/android/app/src/test/java/com/noop/ui/TodayWorkoutTapTest.kt b/android/app/src/test/java/com/noop/ui/TodayWorkoutTapTest.kt index deba608149..b89136353c 100644 --- a/android/app/src/test/java/com/noop/ui/TodayWorkoutTapTest.kt +++ b/android/app/src/test/java/com/noop/ui/TodayWorkoutTapTest.kt @@ -78,4 +78,39 @@ class TodayWorkoutTapTest { swift.contains("LiquidPressStyle()"), ) } + + /** + * #1702: both platforms must window the feed to the same 14 days, and iOS must do it AT THE SECTION. + * + * `workouts` on TodayView is shared: the Data Sources Apple-workout count and the HR chart's sport + * glyphs both read it and are all-time by design. Windowing the array at its source would compile, + * look correct, and silently shrink two unrelated numbers on the same screen. + */ + @Test + fun bothPlatformsWindowTheFeedToTheSameFourteenDays() { + val kotlin = File(repoRoot(), "android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt").readText() + assertTrue( + "Android's feed contract must stay the 14-day window iOS is pinned to", + todayScreenKt().contains(".minusDays(13)"), + ) + assertTrue("lastWorkoutsFeed must remain the Android feed contract", kotlin.contains("fun lastWorkoutsFeed")) + + val swift = File(repoRoot(), "Strand/Screens/TodayView.swift").readText() + assertTrue( + "iOS must window with the same 13-days-back-from-start-of-day cutoff", + swift.contains("value: -13, to: cal.startOfDay(for: now)"), + ) + assertTrue( + "the iOS section must render the WINDOWED feed, not the shared all-time array", + swift.contains("let recent = Self.recentWorkoutsFeed(workouts)") && swift.contains("recent.prefix(6)"), + ) + assertTrue( + "the Data Sources Apple count must keep reading the unwindowed array", + swift.contains("workouts.filter { WorkoutSource.isAppleHealth"), + ) + assertFalse( + "the trailing header must describe the window, not count every workout ever recorded", + swift.contains("\\(workouts.count) total"), + ) + } }