Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
59 changes: 59 additions & 0 deletions StrandTests/TodayWorkoutsWindowTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
35 changes: 35 additions & 0 deletions android/app/src/test/java/com/noop/ui/TodayWorkoutTapTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
}
}
Loading