From ecfeff4e3c121f0f4e471c2c6faeb0d008445ead Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 5 Aug 2026 22:51:09 -0400 Subject: [PATCH] Fix sleep hypnogram alignment, add press-and-hold stage readout, smooth sync spinner --- PulseLoop/DesignSystem/Charts.swift | 265 +++++++++++++++++-- PulseLoop/DesignSystem/Components.swift | 72 ++--- PulseLoop/Services/RingSyncCoordinator.swift | 9 +- PulseLoop/Services/SleepInsights.swift | 6 - PulseLoop/Views/RootViews.swift | 16 +- PulseLoop/Views/SleepView.swift | 16 +- PulseLoop/Views/TodayView.swift | 6 +- PulseLoopTests/SleepHypnogramMathTests.swift | 133 ++++++++++ 8 files changed, 448 insertions(+), 75 deletions(-) create mode 100644 PulseLoopTests/SleepHypnogramMathTests.swift diff --git a/PulseLoop/DesignSystem/Charts.swift b/PulseLoop/DesignSystem/Charts.swift index 8b65769..e860eb9 100644 --- a/PulseLoop/DesignSystem/Charts.swift +++ b/PulseLoop/DesignSystem/Charts.swift @@ -426,20 +426,52 @@ struct SleepHypnogramView: View { let totalMin: Int let startTs: Date? var height: CGFloat = 210 + /// Fired with `true` when a press-and-hold scrub begins and `false` when it ends. The host + /// disables its enclosing scroll views for the duration — the scrub gesture is `simultaneous` + /// so that quick swipes keep paging/scrolling, which means an active scrub would otherwise + /// drag the carousel along with the finger. + var onScrubActiveChanged: ((Bool) -> Void)? = nil private let lanes: [SleepStage] = [.awake, .rem, .light, .deep] - private func laneY(_ stage: SleepStage, in size: CGSize) -> CGFloat { - // awake=top lane, then REM, light, deep=bottom (standard hypnogram ordering). - let frac: CGFloat + /// A press-and-hold scrub selection: which block the finger is over, and the minute under it. + private struct Scrub: Equatable { + var blockIndex: Int + var minute: Int + } + @State private var scrub: Scrub? + /// Whether we've told the host to pause scrolling. Guarded so the callback fires once per + /// transition, and so every unwind path (end, cancel, teardown) can safely call `endScrub()`. + @State private var scrubLockActive = false + /// Mirrors "a scrub touch is on the screen". `@GestureState` resets automatically when the + /// gesture ends, fails, or is CANCELLED (incoming call, Home swipe) — paths where `.onEnded` + /// never runs. `onChange` of this is what keeps the host's scroll lock from leaking. + @GestureState private var scrubTouchActive = false + /// Canvas (plot-rect) size, captured so the gesture can map touch x → minute. + @State private var plotSize: CGSize = .zero + /// Measured readout-pill width, used to clamp it inside the plot. + @State private var tooltipWidth: CGFloat = 0 + + /// Insets of the plot area inside the glass card. The Canvas is padded by exactly these, and the + /// lane labels / scrub overlay derive their coordinates from the same values — a single source of + /// truth so bars and labels cannot drift apart. + static let plotInsets = EdgeInsets(top: 16, leading: 64, bottom: 16, trailing: 16) + private static let labelLeading: CGFloat = 12 + + /// Vertical center of a stage's lane, as a fraction of the plot height. + /// awake=top lane, then REM, light, deep=bottom (standard hypnogram ordering). + static func laneFraction(_ stage: SleepStage) -> CGFloat { switch stage { - case .awake: frac = 0.15 - case .rem: frac = 0.38 - case .light: frac = 0.62 - case .deep: frac = 0.85 - case .unknown: frac = 0.62 + case .awake: return 0.15 + case .rem: return 0.38 + case .light: return 0.62 + case .deep: return 0.85 + case .unknown: return 0.62 } - return size.height * frac + } + + private func laneY(_ stage: SleepStage, in size: CGSize) -> CGFloat { + size.height * Self.laneFraction(stage) } private func x(forMinute minute: Int, in width: CGFloat) -> CGFloat { @@ -468,21 +500,8 @@ struct SleepHypnogramView: View { var body: some View { VStack(spacing: 6) { - ZStack(alignment: .leading) { - // Lane labels on the left. - VStack(alignment: .leading) { - ForEach(lanes, id: \.self) { stage in - Text(stage.rawValue.uppercased()) - .font(PulseFont.micro.weight(.semibold)) - .tracking(1.4) - .foregroundStyle(SleepStageColors.color(for: stage)) - if stage != lanes.last { Spacer() } - } - } - .padding(.vertical, 14) - .padding(.leading, 12) - - // Plot area, inset to clear the labels. + ZStack { + // Plot area, inset to clear the label gutter. Canvas { context, size in let blocks = sortedBlocks guard !blocks.isEmpty else { return } @@ -515,12 +534,48 @@ struct SleepHypnogramView: View { context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: 6.5, lineCap: .round)) } } - .padding(.vertical, 16) - .padding(.leading, 64) - .padding(.trailing, 16) + .contentShape(Rectangle()) + .simultaneousGesture(scrubGesture) + .onGeometryChange(for: CGSize.self, of: { $0.size }) { plotSize = $0 } + .padding(Self.plotInsets) + + // Lane labels + scrub readout, placed with the same `laneFraction`/`plotInsets` + // math the Canvas uses, so label centers coincide with bar centers by construction. + GeometryReader { geo in + let plotWidth = max(1, geo.size.width - Self.plotInsets.leading - Self.plotInsets.trailing) + let plotHeight = max(1, geo.size.height - Self.plotInsets.top - Self.plotInsets.bottom) + let gutterWidth = Self.plotInsets.leading - Self.labelLeading + + ForEach(lanes, id: \.self) { stage in + Text(stage.rawValue.uppercased()) + .font(PulseFont.micro.weight(.semibold)) + .tracking(1.4) + .foregroundStyle(SleepStageColors.color(for: stage)) + .frame(width: gutterWidth, alignment: .leading) + .position(x: Self.labelLeading + gutterWidth / 2, + y: Self.plotInsets.top + Self.laneFraction(stage) * plotHeight) + } + + scrubOverlay(plotWidth: plotWidth, plotHeight: plotHeight, containerWidth: geo.size.width) + } + // Labels and readout are display-only; touches must reach the Canvas gesture below. + .allowsHitTesting(false) } .frame(height: height - 22) .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) + // Tick when the scrub latches a block or crosses into a new one — not on release + // (nil), which would read as a false stage-change cue. + .sensoryFeedback(.selection, trigger: scrub?.blockIndex) { _, new in new != nil } + // The gesture's touch went away by ANY path (ended, failed, system-cancelled): + // release the scroll lock. `.onEnded` alone misses cancellation. + .onChange(of: scrubTouchActive) { _, active in if !active { endScrub() } } + // Mid-scrub teardown (e.g. a sync flips single-session → carousel): the gesture dies + // with the view, so unwind the host's scroll lock here. + .onDisappear { endScrub() } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Sleep stages") + .accessibilityValue(Self.accessibilitySummary( + stages: sortedBlocks.map { (stage: $0.stage, minutes: $0.durationMinutes) })) // Time ticks. HStack { @@ -536,4 +591,160 @@ struct SleepHypnogramView: View { } .frame(height: height) } + + // MARK: Press-and-hold scrubber + + /// Hold ~0.35s, then drag to scrub. Attached as a `simultaneousGesture`: an exclusive `.gesture` + /// here starves the paging ScrollView of the touch stream, killing swipe-to-page over the chart. + /// Simultaneous keeps swipes/scrolls working (a moving finger fails the long-press), and once a + /// scrub actually starts, `onScrubActiveChanged` lets the host pause its scroll views so the + /// carousel doesn't pan under the drag. Coordinates are Canvas-local (attached inside the insets). + private var scrubGesture: some Gesture { + LongPressGesture(minimumDuration: 0.35) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local)) + .updating($scrubTouchActive) { _, state, _ in state = true } + .onChanged { value in + guard case .second(true, let drag) = value else { return } + // Lock the host's scrolling the moment the hold succeeds (drag == nil, finger + // still stationary), and again on the first drag event as belt-and-braces — + // with `minimumDistance: 0` the nil-drag transition isn't guaranteed to be + // delivered. `setScrubLock` is transition-guarded, so this never re-fires. + setScrubLock(true) + guard let drag else { return } + updateScrub(atX: drag.location.x) + } + .onEnded { _ in endScrub() } + } + + private func updateScrub(atX x: CGFloat) { + let blocks = sortedBlocks + let minute = Self.minute(forX: x, plotWidth: plotSize.width, totalMin: totalMin) + guard let index = Self.blockIndex( + atMinute: minute, + in: blocks.map { (start: $0.startMinute, duration: $0.durationMinutes) } + ) else { return } + scrub = Scrub(blockIndex: index, minute: minute) + } + + /// Tell the host to pause/resume scrolling — once per transition. + private func setScrubLock(_ on: Bool) { + guard scrubLockActive != on else { return } + scrubLockActive = on + onScrubActiveChanged?(on) + } + + /// Unwind a scrub from any path: clean gesture end, system cancellation (via the + /// `scrubTouchActive` onChange), or view teardown (onDisappear). Idempotent. + private func endScrub() { + scrub = nil + setScrubLock(false) + } + + /// Vertical indicator line at the finger plus a readout pill above the touched lane + /// ("DEEP · 3:05 – 3:51 AM"). Coordinates are container-local (insets applied here). + @ViewBuilder + private func scrubOverlay(plotWidth: CGFloat, plotHeight: CGFloat, containerWidth: CGFloat) -> some View { + let blocks = sortedBlocks + if let scrub, blocks.indices.contains(scrub.blockIndex) { + let block = blocks[scrub.blockIndex] + let fingerX = Self.plotInsets.leading + x(forMinute: scrub.minute, in: plotWidth) + let laneCenterY = Self.plotInsets.top + Self.laneFraction(block.stage) * plotHeight + + Rectangle() + .fill(PulseColors.textMuted.opacity(0.5)) + .frame(width: 1, height: plotHeight) + .position(x: fingerX, y: Self.plotInsets.top + plotHeight / 2) + + Text(Self.readoutText(stage: block.stage, startMinute: block.startMinute, + durationMinutes: block.durationMinutes, startTs: startTs)) + .font(PulseFont.micro.weight(.semibold).monospacedDigit()) + .foregroundStyle(SleepStageColors.color(for: block.stage)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(.ultraThinMaterial)) + .fixedSize() + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { tooltipWidth = $0 } + .position( + x: { + // Clamp the pill into the plot; if it's wider than the plot itself + // (large Dynamic Type), the bounds invert — center on the card instead + // of pinning it (or hanging it) off the leading edge. + let lo = Self.plotInsets.leading + tooltipWidth / 2 + let hi = containerWidth - Self.plotInsets.trailing - tooltipWidth / 2 + return lo <= hi ? min(max(fingerX, lo), hi) : containerWidth / 2 + }(), + // The pill sits above the touched lane, clear of the 12pt bar halo; the top + // (awake) lane has no room above, so its pill flips below. + y: block.stage == .awake ? laneCenterY + 26 : laneCenterY - 26 + ) + .animation(.easeOut(duration: 0.12), value: scrub.blockIndex) + } + } + + // MARK: Pure helpers (static so they're unit-testable without a view) + + /// Inverse of `x(forMinute:)`: map a plot-local touch x to a minute offset, clamped to the night. + static func minute(forX x: CGFloat, plotWidth: CGFloat, totalMin: Int) -> Int { + guard plotWidth > 0, totalMin > 0 else { return 0 } + let pct = max(0, min(1, x / plotWidth)) + return Int((pct * CGFloat(totalMin)).rounded()) + } + + /// Index of the block containing `minute`, else the nearest block by interval distance — blocks + /// can have small data seams between them, and snapping beats a readout that flickers away. + static func blockIndex(atMinute minute: Int, in blocks: [(start: Int, duration: Int)]) -> Int? { + guard !blocks.isEmpty else { return nil } + if let hit = blocks.firstIndex(where: { minute >= $0.start && minute < $0.start + $0.duration }) { + return hit + } + func distance(to block: (start: Int, duration: Int)) -> Int { + minute < block.start ? block.start - minute : minute - (block.start + block.duration - 1) + } + return blocks.indices.min { distance(to: blocks[$0]) < distance(to: blocks[$1]) } + } + + /// Readout for one block. With a session start the times are absolute ("DEEP · 3:05 – 3:51 AM", + /// both sides fully qualified when they straddle noon/midnight); without one (Coach chart) they + /// are offsets from sleep start ("DEEP · 0:58 – 1:44"), matching the relative tick labels. + static func readoutText(stage: SleepStage, startMinute: Int, durationMinutes: Int, startTs: Date?, + locale: Locale = .current) -> String { + let name = stage.rawValue.uppercased() + let endMinute = startMinute + durationMinutes + guard let startTs else { + func rel(_ m: Int) -> String { "\(m / 60):" + String(format: "%02d", m % 60) } + return "\(name) · \(rel(startMinute)) – \(rel(endMinute))" + } + let start = startTs.addingTimeInterval(Double(startMinute) * 60) + let end = startTs.addingTimeInterval(Double(endMinute) * 60) + let full = DateFormatter() + full.locale = locale + full.dateFormat = "h:mm a" + let calendar = Calendar.current + let sameMeridiem = calendar.isDate(start, inSameDayAs: end) + && (calendar.component(.hour, from: start) < 12) == (calendar.component(.hour, from: end) < 12) + if sameMeridiem { + let short = DateFormatter() + short.locale = locale + short.dateFormat = "h:mm" + return "\(name) · \(short.string(from: start)) – \(full.string(from: end))" + } + return "\(name) · \(full.string(from: start)) – \(full.string(from: end))" + } + + /// VoiceOver summary: per-stage totals in lane order, e.g. + /// "Deep 1 hour 15 minutes, Light 3 hours 15 minutes". + static func accessibilitySummary(stages: [(stage: SleepStage, minutes: Int)]) -> String { + var totals: [SleepStage: Int] = [:] + for entry in stages { totals[entry.stage, default: 0] += entry.minutes } + func plural(_ n: Int, _ unit: String) -> String { "\(n) \(unit)\(n == 1 ? "" : "s")" } + let parts: [String] = [SleepStage.deep, .light, .rem, .awake].compactMap { stage in + guard let minutes = totals[stage], minutes > 0 else { return nil } + let name = stage == .rem ? "REM" : stage.rawValue.capitalized + let h = minutes / 60, m = minutes % 60 + if h > 0 && m > 0 { return "\(name) \(plural(h, "hour")) \(plural(m, "minute"))" } + if h > 0 { return "\(name) \(plural(h, "hour"))" } + return "\(name) \(plural(m, "minute"))" + } + return parts.isEmpty ? "No stage data" : parts.joined(separator: ", ") + } } diff --git a/PulseLoop/DesignSystem/Components.swift b/PulseLoop/DesignSystem/Components.swift index 37adecd..eeb8eb3 100644 --- a/PulseLoop/DesignSystem/Components.swift +++ b/PulseLoop/DesignSystem/Components.swift @@ -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) + } } diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 6fb60d7..35fb67a 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -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? /// Hard ceiling on how long the bar stays up without a fresh progress event. private let syncStallTimeout: UInt64 = 20 @@ -707,6 +710,9 @@ 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() } @@ -714,6 +720,7 @@ final class RingSyncCoordinator { 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) } } diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index d75a91f..0a94bd9 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -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). diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 3a16886..6c4c72e 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -471,16 +471,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 diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index a80189b..d92ddcb 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -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") @@ -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() @@ -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] @@ -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), diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index c6390cf..f92c536 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -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: diff --git a/PulseLoopTests/SleepHypnogramMathTests.swift b/PulseLoopTests/SleepHypnogramMathTests.swift new file mode 100644 index 0000000..9b412f7 --- /dev/null +++ b/PulseLoopTests/SleepHypnogramMathTests.swift @@ -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") + } +}