From aefee97336cdc262770430461ddd1f0aab4eca56 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 28 Aug 2026 19:25:17 +1200 Subject: [PATCH] today: window the iOS Latest Workouts feed to the same 14 days as Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as #1702. The two platforms rendered the same section from different data: Android windows to today-13 at start of day and says "14 days" in the header, while iOS drew from repo.workoutRows(), whose default is days: 4000 — effectively all-time — under a heading that says "Latest", trailed by a count of every workout ever recorded. The visible result was that one account read two ways. Someone who last trained three months ago saw no section at all on Android and six stale sessions on iOS. Reading the call sites suggests iOS's behaviour was never a product decision. Android keeps two sets — recentUnion for the feed, separate all-time queries for the Data Sources counts — while iOS has a single `workouts` array serving both, and the section simply used what was already there. That is also why the window is applied at the SECTION rather than to the array. `workouts` also feeds the Data Sources Apple-workout count and the HR chart's sport glyphs, both all-time by design; windowing it at the source compiles, looks correct, and quietly shrinks two unrelated numbers on the same screen. The trailing header now describes the window on both platforms. "14 days" was already in the string catalogue in all ten locales, so no new copy. Tile caps stay 4 on Android and 6 on iOS. That difference is layout, not content: Android is deliberately single-column full-width (the 2x2 grid truncated durations on narrow phones, #332), iOS uses an adaptive grid. Same vertical budget, and unifying it would change one platform's density for no reader benefit. TodayWorkoutsWindowTests pins the boundary rather than the happy path — a session exactly on the cutoff is kept, one second earlier is dropped — because an off-by-one there silently drops a session the other platform still shows. TodayWorkoutTapTest gains a parity assertion that runs in ordinary CI and checks the Data Sources count still reads the unwindowed array, so the trap is guarded and not merely sidestepped once. Verified: 4,713 Android tests, 0 failures with --rerun-tasks --no-build-cache; i18n audit clean. The Swift half compiles only under app-build. --- Strand/Screens/TodayView.swift | 22 ++++++- StrandTests/TodayWorkoutsWindowTests.swift | 59 +++++++++++++++++++ .../java/com/noop/ui/TodayWorkoutTapTest.kt | 35 +++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 StrandTests/TodayWorkoutsWindowTests.swift 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"), + ) + } }