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
265 changes: 238 additions & 27 deletions PulseLoop/DesignSystem/Charts.swift

Large diffs are not rendered by default.

72 changes: 42 additions & 30 deletions PulseLoop/DesignSystem/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -688,51 +688,63 @@ struct ActivityWorkoutRow: View {
/// A thin, full-width indeterminate progress bar shown under the app header while the ring is
/// syncing. We only have stage labels (not a percentage), so this is indeterminate: an accent
/// segment sweeps left→right over a recessed track. Under Reduce Motion it degrades to a steady
/// pulsing full-width fill (no horizontal travel). Visuals use the existing `PulseColors` tokens
/// and the `ConnectionStatusPill` animation idiom.
/// pulsing full-width fill (no horizontal travel). Visuals use the existing `PulseColors` tokens.
///
/// Driven by `TimelineView(.animation)` with the position a pure function of wall-clock time —
/// never a `@State` + `repeatForever` pair. The bar is mounted behind `if isSyncing`, so state-based
/// animation restarts from phase 0 on every sync (and can be committed against a pre-layout width
/// of 0, which reads as a frozen or jumping bar). Wall-clock math is phase-continuous across
/// remounts and self-corrects after dropped frames instead of accumulating jank.
struct SyncProgressBar: View {
/// Bar thickness in points — deliberately thin so it reads as a status accent, not a control.
var height: CGFloat = 3
/// Fraction of the track width the moving segment occupies.
private let segmentFraction: CGFloat = 0.4
/// One edge-to-edge sweep; the out-and-back cycle is 2×. Matches the previous
/// `.easeInOut(duration: 1.1).repeatForever(autoreverses: true)` look.
private let sweepDuration: Double = 1.1
/// Reduce Motion: half-period of the full-width opacity breathe (was easeInOut(0.8)).
private let pulseHalfPeriod: Double = 0.8

@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animate = false

var body: some View {
GeometryReader { geo in
let trackWidth = geo.size.width
let segmentWidth = trackWidth * segmentFraction

ZStack(alignment: .leading) {
Rectangle().fill(PulseColors.elevated)

if reduceMotion {
// No travel — a gentle opacity pulse on a full-width fill.
Rectangle()
.fill(PulseColors.accent)
.opacity(animate ? 0.55 : 1.0)
} else {
Capsule()
.fill(PulseColors.accent)
.frame(width: segmentWidth)
// Sweep from just off the left edge to just off the right edge.
.offset(x: animate ? (trackWidth - segmentWidth) : 0)
TimelineView(.animation) { timeline in
let t = timeline.date.timeIntervalSinceReferenceDate
GeometryReader { geo in
let trackWidth = geo.size.width
let segmentWidth = trackWidth * segmentFraction

ZStack(alignment: .leading) {
Rectangle().fill(PulseColors.elevated)

if reduceMotion {
// No travel — a gentle opacity pulse on a full-width fill (1.0 ↔ 0.55).
Rectangle()
.fill(PulseColors.accent)
.opacity(1.0 - 0.45 * Self.easedTriangle(t, halfPeriod: pulseHalfPeriod))
} else {
Capsule()
.fill(PulseColors.accent)
.frame(width: segmentWidth)
// Sweep from the left edge to the right edge and back.
.offset(x: (trackWidth - segmentWidth) * Self.easedTriangle(t, halfPeriod: sweepDuration))
}
}
.clipped()
}
.frame(height: height)
.clipped()
}
.frame(height: height)
.onAppear {
if reduceMotion {
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { animate = true }
} else {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { animate = true }
}
}
.accessibilityElement()
.accessibilityLabel("Syncing")
.accessibilityAddTraits(.updatesFrequently)
}

/// 0→1→0 triangle wave of wall-clock time with a sine ease applied per leg — the stateless
/// equivalent of `.easeInOut(halfPeriod).repeatForever(autoreverses: true)`.
private static func easedTriangle(_ t: TimeInterval, halfPeriod: Double) -> CGFloat {
let phase = t.truncatingRemainder(dividingBy: halfPeriod * 2) / halfPeriod // 0..<2
let tri = phase < 1 ? phase : 2 - phase
return CGFloat(0.5 - cos(.pi * tri) / 2)
}
}
9 changes: 8 additions & 1 deletion PulseLoop/Services/RingSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ final class RingSyncCoordinator {
/// safety timeout so a dropped completion signal can't leave the progress bar stuck on.
private(set) var syncStage: String?
/// Whether a ring data sync is in flight — drives the thin progress bar under the header.
var isSyncing: Bool { syncStage != nil }
/// Stored and mutated only on start/end transitions (not derived from `syncStage`): a computed
/// `syncStage != nil` would register observers on `syncStage` itself, invalidating the whole
/// tab tree on every progress packet instead of twice per sync.
private(set) var isSyncing = false
private var syncTimeoutTask: Task<Void, Never>?
/// Hard ceiling on how long the bar stays up without a fresh progress event.
private let syncStallTimeout: UInt64 = 20
Expand Down Expand Up @@ -707,13 +710,17 @@ final class RingSyncCoordinator {
lastSyncAt = Date()
guard stage != "done" else { endSync(); return }
syncStage = stage
// Transition-guarded: Observation notifies on every set (no equality check), so an
// unconditional write here would put the per-packet churn back into every observer.
if !isSyncing { isSyncing = true }
armSyncTimeout()
}

private func endSync() {
syncTimeoutTask?.cancel()
syncTimeoutTask = nil
syncStage = nil
if isSyncing { isSyncing = false }
// The sync just ended (done / disconnect / stall timeout) — wake anyone waiting on it.
for id in Array(syncWaiters.keys) { resumeSyncWaiter(id) }
}
Expand Down
6 changes: 0 additions & 6 deletions PulseLoop/Services/SleepInsights.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,6 @@ enum SleepInsights {
)
}

static let dayNoDataCoach = SleepCoach(
headline: "No sleep tracked last night",
body: "I don't see sleep data for last night. Wear your ring overnight and sync in the morning so I can compare your sleep against your baseline.",
chips: []
)

static func aggregateCoach(range: SleepRangeKey, sessions: [SleepSummary], expectedNights: Int, goalMin: Int?) -> SleepCoach {
// Collapse naps into their day so "N nights tracked" counts distinct nights, matching the
// collapsed average this copy sits next to (a night + 2 naps is 1 night, not 3).
Expand Down
16 changes: 12 additions & 4 deletions PulseLoop/Views/RootViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -484,16 +484,24 @@ struct ConnectionStatusPill: View {
.pulseGlass(Capsule(), interactive: true)
.overlay(Capsule().stroke(PulseColors.borderSubtle, lineWidth: 1))
.fixedSize(horizontal: true, vertical: false)
.onAppear {
guard isPulsing else { return }
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { pulse = true }
}
.onAppear { startPulse(isPulsing) }
// The pill stays mounted across state changes, so `onAppear` alone would miss a later
// idle → connecting flip — and would leave the repeatForever loop running after connect.
.onChange(of: isPulsing) { _, now in startPulse(now) }
}

private var isPulsing: Bool {
state == .connecting || state == .reconnecting
}

/// Restart or cancel the dot pulse. The non-animated reset replaces the old repeatForever
/// transaction; without it the loop keeps animating the header for the app's whole lifetime.
private func startPulse(_ on: Bool) {
pulse = false
guard on else { return }
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { pulse = true }
}

private var dotColor: Color {
switch state {
case .connected: return PulseColors.success
Expand Down
16 changes: 10 additions & 6 deletions PulseLoop/Views/SleepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ struct SleepView: View {
/// Observed so the tab re-fetches when a background sync writes new sleep data (matches
/// Today/Vitals). Without it, `sleepRange` is a plain call the body never re-runs on sync.
@State private var dataChange = PulseDataChange.shared
/// True while a press-and-hold scrub is active on a hypnogram. Pauses this screen's scroll
/// views (outer vertical + session carousel) so the scrub drag doesn't pan them.
@State private var hypnogramScrubbing = false

init() {
let raw = UserDefaults.standard.string(forKey: "startSleepRange")
Expand Down Expand Up @@ -60,6 +63,8 @@ struct SleepView: View {
.padding(.horizontal, 16)
.padding(.bottom, 96)
}
// Applies to every scrollable below (outer + carousel), so an active scrub owns the drag.
.scrollDisabled(hypnogramScrubbing)
.background(PulseColors.background)
.refreshable { await coordinator.pullToRefresh() }
.pulseScrollEdges()
Expand Down Expand Up @@ -105,16 +110,14 @@ struct SleepView: View {
private func dayView(summary: SleepRangeSummary, activitySteps: Int?, isToday: Bool) -> some View {
let sessions = SleepInsights.validSessions(summary.sessions).sorted { $0.session.startAt < $1.session.startAt }
if sessions.isEmpty {
let noData = SleepInsights.noDataState(.day)
SleepHeroCardView(label: noData.label, value: noData.value, support: noData.support, score: nil, noData: true)
// The "wear your ring" explainer lives in the architecture card ONLY — the hero and
// stage cards just dash out, so the message isn't repeated all over one page.
SleepHeroCardView(label: SleepInsights.rangeHeroLabel[.day] ?? "Last Sleep", value: "—", score: nil)
VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: false) {
InlineEmptyState(title: "No sleep recorded", message: "Wear your ring overnight to see your hypnogram here.")
.frame(height: 180)
}
SleepStageSummaryCardsView(deep: "—", light: "—", awake: "—")
if coachEnabled {
CoachMessageCard(headline: SleepInsights.dayNoDataCoach.headline, body: SleepInsights.dayNoDataCoach.body, chips: SleepInsights.dayNoDataCoach.chips)
}
} else {
// The primary (longest) session drives the day-level coach fallback.
let primary = sessions.max { $0.session.totalMinutes < $1.session.totalMinutes } ?? sessions[0]
Expand Down Expand Up @@ -145,7 +148,8 @@ struct SleepView: View {
scoreLabel: score.label.rawValue
)
VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: true) {
SleepHypnogramView(blocks: s.blocks, totalMin: s.session.totalMinutes, startTs: s.session.startAt)
SleepHypnogramView(blocks: s.blocks, totalMin: s.session.totalMinutes, startTs: s.session.startAt,
onScrubActiveChanged: { hypnogramScrubbing = $0 })
}
SleepStageSummaryCardsView(
deep: SleepFormat.duration(s.deepMinutes),
Expand Down
6 changes: 5 additions & 1 deletion PulseLoop/Views/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,11 @@ struct TodayView: View {
case .steps:
ActivityTileView(
summary: store.summary, units: units,
caloriesAvailable: MetricsService.isVisible(.calories, context: modelContext, scope: .today),
// From the store's per-rebuild snapshot — never a SwiftData fetch on the body path
// (this body re-runs on every store/prefs change). `.calories` can't be hidden
// (Settings folds it into the Activity tile), so this reduces to the device
// capability gate, and capability changes already flow through the store rebuild.
caloriesAvailable: store.visibleMetrics.contains(.calories),
onTap: { selectedTab = .activity }
)
case .nutrition:
Expand Down
133 changes: 133 additions & 0 deletions PulseLoopTests/SleepHypnogramMathTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import XCTest
@testable import PulseLoop

/// Locks the hypnogram's shared lane geometry (the label/bar alignment fix) and the pure math
/// behind the press-and-hold scrubber: touch-x → minute, minute → block, and the readout text.
final class SleepHypnogramMathTests: XCTestCase {

// MARK: Lane geometry

func testLaneFractionsAreOrderedTopToBottom() {
// Standard hypnogram ordering: awake on top, deep at the bottom.
let awake = SleepHypnogramView.laneFraction(.awake)
let rem = SleepHypnogramView.laneFraction(.rem)
let light = SleepHypnogramView.laneFraction(.light)
let deep = SleepHypnogramView.laneFraction(.deep)
XCTAssertLessThan(awake, rem)
XCTAssertLessThan(rem, light)
XCTAssertLessThan(light, deep)
XCTAssertGreaterThan(awake, 0)
XCTAssertLessThan(deep, 1)
}

func testUnknownStageSharesTheLightLane() {
XCTAssertEqual(SleepHypnogramView.laneFraction(.unknown), SleepHypnogramView.laneFraction(.light))
}

// MARK: Touch x → minute

func testMinuteForXClampsToNight() {
XCTAssertEqual(SleepHypnogramView.minute(forX: -50, plotWidth: 300, totalMin: 480), 0)
XCTAssertEqual(SleepHypnogramView.minute(forX: 350, plotWidth: 300, totalMin: 480), 480)
}

func testMinuteForXGuardsDegenerateInputs() {
XCTAssertEqual(SleepHypnogramView.minute(forX: 100, plotWidth: 0, totalMin: 480), 0)
XCTAssertEqual(SleepHypnogramView.minute(forX: 100, plotWidth: 300, totalMin: 0), 0)
}

func testMinuteForXMidpointAndEdges() {
XCTAssertEqual(SleepHypnogramView.minute(forX: 150, plotWidth: 300, totalMin: 480), 240)
XCTAssertEqual(SleepHypnogramView.minute(forX: 0, plotWidth: 300, totalMin: 480), 0)
XCTAssertEqual(SleepHypnogramView.minute(forX: 300, plotWidth: 300, totalMin: 480), 480)
}

// MARK: Minute → block

private let blocks: [(start: Int, duration: Int)] = [
(start: 0, duration: 58), // light
(start: 58, duration: 46), // deep
(start: 110, duration: 70), // light — note the 6-minute seam after the deep block
(start: 180, duration: 22), // rem
]

func testBlockIndexEmptyReturnsNil() {
XCTAssertNil(SleepHypnogramView.blockIndex(atMinute: 10, in: []))
}

func testBlockIndexContainment() {
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 0, in: blocks), 0)
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 57, in: blocks), 0)
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 58, in: blocks), 1, "block end is exclusive — minute 58 belongs to the next block")
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 150, in: blocks), 2)
}

func testBlockIndexSnapsGapToNearestBlock() {
// The 104–110 seam: 105 is closer to the deep block ending at 103, 109 to the light block at 110.
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 105, in: blocks), 1)
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 109, in: blocks), 2)
}

func testBlockIndexSnapsOutOfRangeToEnds() {
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: -5, in: blocks), 0)
XCTAssertEqual(SleepHypnogramView.blockIndex(atMinute: 999, in: blocks), 3, "past the last block snaps to it (incl. minute == night end)")
}

// MARK: Readout text

/// 2:15 AM local, matching the TestFlight screenshot's night.
private var nightStart: Date {
var components = DateComponents()
components.year = 2026; components.month = 7; components.day = 9
components.hour = 2; components.minute = 15
return Calendar.current.date(from: components)!
}

/// Fixed locale so the expected AM/PM strings don't depend on the test host's region.
private let posix = Locale(identifier: "en_US_POSIX")

func testReadoutTextAbsoluteSameMeridiem() {
// 50 → 96 min after 2:15 AM: 3:05 – 3:51, both AM, so only the end carries the meridiem.
let text = SleepHypnogramView.readoutText(stage: .deep, startMinute: 50, durationMinutes: 46,
startTs: nightStart, locale: posix)
XCTAssertEqual(text, "DEEP · 3:05 – 3:51 AM")
}

func testReadoutTextAbsoluteCrossesMeridiem() {
// A block spanning 11:45 AM → 12:15 PM: both sides must carry their meridiem.
let text = SleepHypnogramView.readoutText(stage: .light, startMinute: 570, durationMinutes: 30,
startTs: nightStart, locale: posix)
XCTAssertEqual(text, "LIGHT · 11:45 AM – 12:15 PM")
}

func testReadoutTextRelativeWhenNoStartTimestamp() {
// The Coach chart passes startTs == nil; times are offsets from sleep start.
let text = SleepHypnogramView.readoutText(stage: .deep, startMinute: 58, durationMinutes: 46, startTs: nil)
XCTAssertEqual(text, "DEEP · 0:58 – 1:44")
}

func testReadoutTextRelativeZeroPadsMinutes() {
let text = SleepHypnogramView.readoutText(stage: .rem, startMinute: 120, durationMinutes: 5, startTs: nil)
XCTAssertEqual(text, "REM · 2:00 – 2:05")
}

// MARK: Accessibility summary

func testAccessibilitySummaryAggregatesSplitBlocksInLaneOrder() {
let summary = SleepHypnogramView.accessibilitySummary(stages: [
(stage: .light, minutes: 58), (stage: .deep, minutes: 46),
(stage: .light, minutes: 70), (stage: .rem, minutes: 22),
(stage: .deep, minutes: 29),
])
XCTAssertEqual(summary, "Deep 1 hour 15 minutes, Light 2 hours 8 minutes, REM 22 minutes")
}

func testAccessibilitySummarySingularUnits() {
let summary = SleepHypnogramView.accessibilitySummary(stages: [(stage: .deep, minutes: 61)])
XCTAssertEqual(summary, "Deep 1 hour 1 minute")
}

func testAccessibilitySummaryEmpty() {
XCTAssertEqual(SleepHypnogramView.accessibilitySummary(stages: []), "No stage data")
}
}
Loading