diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift index fd386555fe..d290e5f8d8 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -29,13 +29,17 @@ public struct OverviewHRChart: View { } /// A workout window; the sport glyph is placed at the HR peak inside [start, end]. - public struct WorkoutSpan: Identifiable, Sendable { - public let id = UUID() + public struct WorkoutSpan: Identifiable, Sendable, Hashable { + // Today reconstructs annotations on redraw; unchanged spans must retain chart identity. + public var id: Self { self } public var start: Date public var end: Date public var symbol: String // SF Symbol (see `sportSymbol`) - public init(start: Date, end: Date, symbol: String) { + public var sport: String? + public var source: String? + public init(start: Date, end: Date, symbol: String, sport: String? = nil, source: String? = nil) { self.start = start; self.end = end; self.symbol = symbol + self.sport = sport; self.source = source } } @@ -150,6 +154,10 @@ public struct OverviewHRChart: View { /// Smallest zoom window we allow (1 minute) — past this the line is just two points and pinch jitters. public static let minZoomSpan: TimeInterval = 60 + static func isHorizontalPan(_ translation: CGSize) -> Bool { + abs(translation.width) > abs(translation.height) + } + // MARK: Zoom / pan math (pure, testable in isolation) /// The visible domain after scaling `base` about `anchorFraction` (0…1 across the window) by @@ -711,6 +719,9 @@ private struct ZoomPanModifier: ViewModifier { // the 6–8 pt overlap band is what let a held-then-dragged finger begin panning during a scrub. let drag = DragGesture(minimumDistance: 10) .onChanged { value in + #if os(iOS) + guard OverviewHRChart.isHorizontalPan(value.translation) else { return } + #endif let base = anchor ?? current() if anchor == nil { anchor = base } apply(pan(base, value.translation.width, plotWidth, bounds)) diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift index 1a7ca217c1..414ee1742c 100644 --- a/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift +++ b/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift @@ -20,6 +20,32 @@ final class OverviewHRChartAnnotationTests: XCTestCase { /// A day window: 86 400 s starting at t=100 000 (arbitrary epoch, values only matter relatively). private let day: ClosedRange = Date(timeIntervalSince1970: 100_000)...Date(timeIntervalSince1970: 186_400) + func testRebuiltWorkoutAnnotationsKeepIdentity() { + let original = workout(110_000, 113_600) + XCTAssertEqual(original.id, workout(110_000, 113_600).id) + XCTAssertNotEqual(original.id, workout(110_000, 114_000).id) + let otherSport = OverviewHRChart.WorkoutSpan(start: original.start, end: original.end, + symbol: "figure.walk") + XCTAssertNotEqual(original.id, otherSport.id) + let saved = OverviewHRChart.WorkoutSpan(start: original.start, end: original.end, + symbol: "figure.run", sport: "Running", source: "manual") + let imported = OverviewHRChart.WorkoutSpan(start: original.start, end: original.end, + symbol: "figure.run", sport: "Running", source: "import") + XCTAssertNotEqual(saved.id, imported.id, "Coincident annotations from different sources remain distinct") + } + + func testVerticalScrollingDoesNotPanTheChart() { + for dx in [-8.0, 0, 8] { + for dy in [-30.0, 30] { + XCTAssertFalse(OverviewHRChart.isHorizontalPan(CGSize(width: dx, height: dy))) + } + } + XCTAssertFalse(OverviewHRChart.isHorizontalPan(.zero)) + XCTAssertFalse(OverviewHRChart.isHorizontalPan(CGSize(width: 20, height: 20))) + XCTAssertTrue(OverviewHRChart.isHorizontalPan(CGSize(width: -30, height: 8))) + XCTAssertTrue(OverviewHRChart.isHorizontalPan(CGSize(width: 30, height: -8))) + } + // MARK: mainSleep — the main night, never a nap /// The LONGEST overlapping block wins, exactly like the classic Today: a 7h night beats a 40m nap. diff --git a/Strand/Screens/FullDayChartView.swift b/Strand/Screens/FullDayChartView.swift index 85b567ef17..3c85fa304f 100644 --- a/Strand/Screens/FullDayChartView.swift +++ b/Strand/Screens/FullDayChartView.swift @@ -404,7 +404,7 @@ struct FullDayChartView: View { .map { w in .init(start: Date(timeIntervalSince1970: TimeInterval(w.startTs)), end: Date(timeIntervalSince1970: TimeInterval(w.endTs)), - symbol: sportSymbol(w.sport)) + symbol: sportSymbol(w.sport), sport: w.sport, source: w.source) } guard !Task.isCancelled else { return } // The pure, headless-tested selection (StrandDesignTests) — window = the shown DAY, not the zoom. diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 4935abfd9c..1e9d6cc746 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -3774,7 +3774,7 @@ struct TodayView: View { let start = Date(timeIntervalSince1970: TimeInterval(w.startTs)) let end = Date(timeIntervalSince1970: TimeInterval(w.endTs)) guard end >= win.lowerBound, start <= win.upperBound else { return nil } - return .init(start: start, end: end, symbol: sportSymbol(w.sport)) + return .init(start: start, end: end, symbol: sportSymbol(w.sport), sport: w.sport, source: w.source) } }