Skip to content
Open
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
17 changes: 14 additions & 3 deletions Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> = 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.
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/FullDayChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading