From 41576df015f1e723718d424e75466107883ecf54 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:52:17 +1000 Subject: [PATCH 1/6] feat(sleep): split same-day sleep into sessions + Day carousel (#59) Ring history holding a nap plus the main night on one waking day was merged into a single ~16h SleepSession with a broken hypnogram. Split sleep into distinct sessions and page between them on the Day view. - SleepSegmentation.segment + SleepService.reconcileWakingDay derive a day's sessions from its stage blocks (a >=60 min gap in recorded sleep starts a new session); idempotent. - persistSleepTimeline accumulates a packet's blocks, then reconciles. - migrateSleepSessionSegmentsIfNeeded (sleepSessionSegment.v1) one-time re-splits legacy merged days. - Sleep > Day: horizontal carousel + dots + per-session caption; single-session days render unchanged. - SleepInsights.collapseByDay: aggregate views count each day once, so a nap never skews the nightly average or "nights tracked". - sleepForDate returns the day's longest session. - 11 new tests (segmentation, reconcile idempotency, migration). Closes #59 --- PulseLoop/Events/PulseEventBus.swift | 33 ++--- PulseLoop/PulseLoopApp.swift | 4 + PulseLoop/Services/PulseServices.swift | 126 +++++++++++++++- PulseLoop/Services/SleepInsights.swift | 77 +++++++++- PulseLoop/Views/SleepView.swift | 101 ++++++++++--- PulseLoopTests/SleepServiceTests.swift | 198 +++++++++++++++++++++++++ 6 files changed, 493 insertions(+), 46 deletions(-) diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 6f5d3339..cbf9569a 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -455,19 +455,24 @@ final class EventPersistenceSubscriber { // `Calendar.wakingDay(forSleepStart:)`. let dateKey = calendar.wakingDay(forSleepStart: start) + // Attach this packet's per-minute blocks to the day's container session (create one if the + // day is empty), deduping by block start across every session on the day. Then hand off to + // `SleepService.reconcileWakingDay`, which re-splits the day's blocks into distinct sessions + // (main night vs. naps separated by a >= 60 min gap) and recomputes each session's bounds. let allSessions = (try? context.fetch(FetchDescriptor())) ?? [] - let session = allSessions.first { calendar.isDate($0.date, inSameDayAs: dateKey) } + let daySessions = allSessions.filter { calendar.isDate($0.date, inSameDayAs: dateKey) } + let container = daySessions.min { $0.startAt < $1.startAt } ?? { let new = SleepSession(date: dateKey, startAt: start, endAt: start, totalMinutes: 0, syncedAt: Date()) context.insert(new) return new }() - func blocks(for sessionId: UUID) -> [SleepStageBlock] { - ((try? context.fetch(FetchDescriptor())) ?? []).filter { $0.sessionId == sessionId } - } + let daySessionIds = Set(daySessions.map { $0.id }).union([container.id]) + var existingStarts = Set(((try? context.fetch(FetchDescriptor())) ?? []) + .filter { daySessionIds.contains($0.sessionId) } + .map { $0.startAt }) - var existingStarts = Set(blocks(for: session.id).map { $0.startAt }) var offset = 0 while offset < stages.count { let stage = stages[offset] @@ -477,26 +482,12 @@ final class EventPersistenceSubscriber { } let blockStart = calendar.date(byAdding: .minute, value: offset, to: start) ?? start if !existingStarts.contains(blockStart) { - context.insert(SleepStageBlock(sessionId: session.id, startAt: blockStart, startMinute: 0, durationMinutes: duration, stage: stage)) + context.insert(SleepStageBlock(sessionId: container.id, startAt: blockStart, startMinute: 0, durationMinutes: duration, stage: stage)) existingStarts.insert(blockStart) } offset += duration } - let sorted = blocks(for: session.id).sorted { $0.startAt < $1.startAt } - guard let first = sorted.first else { return } - let sessionStart = first.startAt - let sessionEnd = sorted - .map { calendar.date(byAdding: .minute, value: $0.durationMinutes, to: $0.startAt) ?? $0.startAt } - .max() ?? sessionStart - for block in sorted { - block.startMinute = max(0, Int(block.startAt.timeIntervalSince(sessionStart) / 60)) - } - session.startAt = sessionStart - session.endAt = sessionEnd - session.totalMinutes = max(0, Int(sessionEnd.timeIntervalSince(sessionStart) / 60)) - session.syncedAt = Date() - session.updatedAt = Date() - context.insert(DerivedUpdateRow(kind: "sleep_timeline", entityType: "sleep_session", entityId: session.id.uuidString)) + SleepService.reconcileWakingDay(dateKey: dateKey, context: context) } } diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index d7027977..f02baf7c 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -65,6 +65,10 @@ struct PulseLoopApp: App { // One-time merge of sleep sessions split across midnight by the old start-of-day grouping. SleepService.migrateSplitSleepSessionsIfNeeded(context: container.mainContext) + // One-time re-split of days whose nap merged into that morning's night (issue #59): re-derive + // each waking day into distinct sessions so the carousel can page through them. + SleepService.migrateSleepSessionSegmentsIfNeeded(context: container.mainContext) + // A persisted "connected" state can't survive a restart — the live link is gone. Reset it so // the UI never shows a false "Connected" until a real connection re-confirms. if !runningTests { diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 8add8816..f21d454f 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -559,6 +559,42 @@ extension Calendar { } } +/// Splits a waking-day's sleep into distinct sessions — the main night vs. daytime +/// naps — so each can surface as its own carousel page (issue #59). Pure and +/// testable; the SwiftData-facing reconciliation lives in `SleepService`. +enum SleepSegmentation { + /// A run of >= this many minutes with no recorded sleep data marks a boundary + /// between two sessions. The ring writes a contiguous per-minute block for each + /// timeline packet, so short mid-night awakenings stay in one session while a nap + /// hours after the night (a genuine gap in the blocks) splits into its own. + static let sessionGapMinutes = 60 + + /// Group blocks into chronological sessions, splitting wherever the gap from one + /// block's end to the next block's start is >= `sessionGapMinutes`. Input need not + /// be sorted. Returns time-ordered groups; empty in -> empty out. + static func segment(_ blocks: [SleepStageBlock]) -> [[SleepStageBlock]] { + let sorted = blocks.sorted { $0.startAt < $1.startAt } + guard let firstBlock = sorted.first else { return [] } + let gapSeconds = Double(sessionGapMinutes) * 60 + var groups: [[SleepStageBlock]] = [] + var current: [SleepStageBlock] = [firstBlock] + var prevEnd = firstBlock.startAt.addingTimeInterval(Double(firstBlock.durationMinutes) * 60) + for block in sorted.dropFirst() { + if block.startAt.timeIntervalSince(prevEnd) >= gapSeconds { + groups.append(current) + current = [block] + } else { + current.append(block) + } + // `max` guards against nested/overlapping blocks pulling the running end backwards. + let end = block.startAt.addingTimeInterval(Double(block.durationMinutes) * 60) + prevEnd = max(prevEnd, end) + } + groups.append(current) + return groups + } +} + @MainActor enum SleepService { static func latestSleep(context: ModelContext) -> SleepSummary? { @@ -572,9 +608,12 @@ enum SleepService { static func sleepForDate(_ date: Date, context: ModelContext) -> SleepSummary? { let start = Calendar.current.startOfDay(for: date) - guard let session = SleepRepository.sessions(context: context).first(where: { Calendar.current.isDate($0.date, inSameDayAs: start) }) else { - return nil - } + // A day can now hold several sessions (night + naps). Surface the main sleep — + // the longest — so single-session callers (Today card) show the night, not a nap. + let session = SleepRepository.sessions(context: context) + .filter { Calendar.current.isDate($0.date, inSameDayAs: start) } + .max { $0.totalMinutes < $1.totalMinutes } + guard let session else { return nil } return summary(for: session, includeStages: true, context: context) } @@ -698,6 +737,87 @@ enum SleepService { try? context.save() } + + /// Re-derive one waking day's sessions from its raw stage blocks: split by + /// `SleepSegmentation`, then reconcile the SwiftData rows — reuse existing rows in + /// time order, insert any missing, delete extras — re-point every block to its + /// segment and recompute session bounds. Idempotent: re-running on the same blocks + /// yields the same rows. Called after each timeline packet and from the one-time + /// migration. Does not save; the caller owns the transaction boundary. + static func reconcileWakingDay(dateKey: Date, context: ModelContext) { + let calendar = Calendar.current + let day = calendar.startOfDay(for: dateKey) + + let allSessions = (try? context.fetch(FetchDescriptor())) ?? [] + let daySessions = allSessions + .filter { calendar.isDate($0.date, inSameDayAs: day) } + .sorted { $0.startAt < $1.startAt } + guard !daySessions.isEmpty else { return } + + let daySessionIds = Set(daySessions.map { $0.id }) + let dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { daySessionIds.contains($0.sessionId) } + + let segments = SleepSegmentation.segment(dayBlocks) + + // No blocks left on this day — drop the empty rows entirely. + guard !segments.isEmpty else { + for row in daySessions { context.delete(row) } + return + } + + for (index, segment) in segments.enumerated() { + let sorted = segment.sorted { $0.startAt < $1.startAt } + guard let segStart = sorted.first?.startAt else { continue } + let segEnd = sorted + .map { $0.startAt.addingTimeInterval(Double($0.durationMinutes) * 60) } + .max() ?? segStart + + let row: SleepSession + if index < daySessions.count { + row = daySessions[index] + } else { + row = SleepSession(date: day, startAt: segStart, endAt: segEnd, totalMinutes: 0, syncedAt: Date()) + context.insert(row) + } + + for block in sorted { + block.sessionId = row.id + block.startMinute = max(0, Int(block.startAt.timeIntervalSince(segStart) / 60)) + } + row.date = day + row.startAt = segStart + row.endAt = segEnd + row.totalMinutes = max(0, Int(segEnd.timeIntervalSince(segStart) / 60)) + row.syncedAt = Date() + row.updatedAt = Date() + context.insert(DerivedUpdateRow(kind: "sleep_timeline", entityType: "sleep_session", entityId: row.id.uuidString)) + } + + // Every block was re-pointed to a segment row above, so any rows beyond the + // segment count are now empty — remove them. + if daySessions.count > segments.count { + for row in daySessions[segments.count...] { context.delete(row) } + } + } + + /// One-time re-segmentation of existing sleep sessions that were persisted before + /// per-session splitting: an afternoon nap used to merge into that morning's night + /// (one row spanning ~16 h with a broken hypnogram). Re-derives each waking day into + /// distinct sessions. Idempotent + `UserDefaults`-gated so it runs once, off the + /// render path. Mirrors `migrateSplitSleepSessionsIfNeeded`. + static func migrateSleepSessionSegmentsIfNeeded(context: ModelContext) { + let key = "sleepSessionSegment.v1" + guard !UserDefaults.standard.bool(forKey: key) else { return } + let calendar = Calendar.current + let sessions = (try? context.fetch(FetchDescriptor())) ?? [] + let days = Set(sessions.map { calendar.startOfDay(for: $0.date) }) + for day in days { + reconcileWakingDay(dateKey: day, context: context) + } + try? context.save() + UserDefaults.standard.set(true, forKey: key) + } } @MainActor diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index 8d6cbdf8..9fba08b0 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -152,18 +152,89 @@ enum SleepInsights { sessions.filter { $0.session.totalMinutes > 0 } } + /// Collapse each calendar (waking) day's sessions into ONE combined `SleepSummary` so + /// that a main night plus daytime naps count as a single tracked day for aggregate math. + /// + /// Pure and deterministic. Days with exactly one session pass through unchanged. For days + /// with multiple sessions, stage minutes are summed, blocks are concatenated (sorted by + /// `startAt`), and a combined detached `SleepSession` carries the day's start-of-day date, + /// earliest `startAt`, latest `endAt`, the SUM of `totalMinutes` (total time asleep across + /// the day, not the wall-clock span), and a duration-weighted average of non-nil scores. + /// The result is sorted by session date. + static func collapseByDay(_ sessions: [SleepSummary]) -> [SleepSummary] { + let calendar = Calendar.current + let grouped = Dictionary(grouping: sessions) { calendar.startOfDay(for: $0.session.date) } + + var collapsed: [SleepSummary] = [] + collapsed.reserveCapacity(grouped.count) + + for (day, daySessions) in grouped { + guard daySessions.count > 1 else { + if let only = daySessions.first { collapsed.append(only) } + continue + } + + let lightMinutes = daySessions.reduce(0) { $0 + $1.lightMinutes } + let deepMinutes = daySessions.reduce(0) { $0 + $1.deepMinutes } + let awakeMinutes = daySessions.reduce(0) { $0 + $1.awakeMinutes } + let blocks = daySessions.flatMap { $0.blocks }.sorted { $0.startAt < $1.startAt } + + let totalMinutes = daySessions.reduce(0) { $0 + $1.session.totalMinutes } + let startAt = daySessions.map { $0.session.startAt }.min() ?? day + let endAt = daySessions.map { $0.session.endAt }.max() ?? day + + let scored = daySessions.compactMap { s -> (score: Int, weight: Int)? in + guard let score = s.session.score else { return nil } + return (score, max(0, s.session.totalMinutes)) + } + let combinedScore: Int? + let totalWeight = scored.reduce(0) { $0 + $1.weight } + if scored.isEmpty { + combinedScore = nil + } else if totalWeight > 0 { + let weightedSum = scored.reduce(0.0) { $0 + Double($1.score) * Double($1.weight) } + combinedScore = Int((weightedSum / Double(totalWeight)).rounded()) + } else { + // All contributing sessions have zero duration; fall back to a plain average. + let plainSum = scored.reduce(0) { $0 + $1.score } + combinedScore = Int((Double(plainSum) / Double(scored.count)).rounded()) + } + + let combinedSession = SleepSession( + date: day, + startAt: startAt, + endAt: endAt, + totalMinutes: totalMinutes, + score: combinedScore + ) + + collapsed.append(SleepSummary( + session: combinedSession, + lightMinutes: lightMinutes, + deepMinutes: deepMinutes, + awakeMinutes: awakeMinutes, + blocks: blocks + )) + } + + return collapsed.sorted { $0.session.date < $1.session.date } + } + static func averageDuration(_ valid: [SleepSummary]) -> Int? { + let valid = collapseByDay(valid) guard !valid.isEmpty else { return nil } return valid.reduce(0) { $0 + $1.session.totalMinutes } / valid.count } static func averageScore(_ valid: [SleepSummary]) -> Int? { + let valid = collapseByDay(valid) guard !valid.isEmpty else { return nil } let total = valid.reduce(0) { $0 + SleepScore.calculate($1).score } return Int((Double(total) / Double(valid.count)).rounded()) } static func averageStages(_ valid: [SleepSummary]) -> (deep: Int, light: Int, awake: Int)? { + let valid = collapseByDay(valid) guard !valid.isEmpty else { return nil } let deep = valid.reduce(0) { $0 + $1.deepMinutes } / valid.count let light = valid.reduce(0) { $0 + $1.lightMinutes } / valid.count @@ -305,6 +376,9 @@ extension SleepInsights { /// carrying its session's duration/score or nil for an untracked night. static func buildNightAxis(start: Date, end: Date, sessions: [SleepSummary], range: SleepRangeKey) -> [SleepBar] { let calendar = Calendar.current + // Collapse each waking day's sessions (main night + naps) into one before mapping, so a + // day is represented by its combined summary rather than an arbitrary first session. + let sessions = collapseByDay(sessions) let byDate: [Date: SleepSummary] = Dictionary( sessions.map { (calendar.startOfDay(for: $0.session.date), $0) }, uniquingKeysWith: { first, _ in first } @@ -335,7 +409,8 @@ extension SleepInsights { static func buildMonthBuckets(end: Date, sessions: [SleepSummary]) -> [SleepBar] { var calendar = Calendar.current calendar.timeZone = .current - let valid = validSessions(sessions) + // Collapse naps into their waking day before bucketing so each day counts once per month. + let valid = collapseByDay(validSessions(sessions)) var byMonth: [String: [SleepSummary]] = [:] for session in valid { let comps = calendar.dateComponents([.year, .month], from: session.session.date) diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index 8285e3fa..a72e5fc2 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -5,8 +5,11 @@ struct SleepView: View { @Environment(\.modelContext) private var modelContext @Environment(RingSyncCoordinator.self) private var coordinator @Query private var allSummaries: [CoachSummary] + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var range: SleepRangeKey @State private var coachStore = CoachSettingsStore.shared + @State private var selectedSleepPage = 0 + @State private var scrolledSleepPage: Int? init() { let raw = UserDefaults.standard.string(forKey: "startSleepRange") @@ -72,27 +75,8 @@ struct SleepView: View { @ViewBuilder private func dayView(summary: SleepRangeSummary, activitySteps: Int?) -> some View { - if let night = SleepInsights.validSessions(summary.sessions).last { - let score = SleepScore.calculate(night) - let coach = SleepInsights.dayCoach(night, score: score.score, awakePct: score.awakePct, deepPct: score.deepPct, activitySteps: activitySteps) - // Hero card opens the page; coach card closes it. - SleepHeroCardView( - label: SleepInsights.rangeHeroLabel[.day] ?? "Last Sleep", - value: SleepFormat.duration(night.session.totalMinutes), - support: "\(SleepFormat.clockTime(night.session.startAt)) to \(SleepFormat.clockTime(night.session.endAt))", - score: score.score, - scoreLabel: score.label.rawValue - ) - VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: true) { - SleepHypnogramView(blocks: night.blocks, totalMin: night.session.totalMinutes, startTs: night.session.startAt) - } - SleepStageSummaryCardsView( - deep: SleepFormat.duration(night.deepMinutes), - light: SleepFormat.duration(night.lightMinutes), - awake: SleepFormat.duration(night.awakeMinutes) - ) - summaryCard(daySummary, fallback: coach) - } else { + 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) VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: false) { @@ -103,7 +87,82 @@ struct SleepView: View { 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] + let primaryScore = SleepScore.calculate(primary) + let dayFallback = SleepInsights.dayCoach(primary, score: primaryScore.score, awakePct: primaryScore.awakePct, deepPct: primaryScore.deepPct, activitySteps: activitySteps) + + if sessions.count == 1 { + // Single session: render exactly as before, no carousel chrome. + sessionPage(sessions[0]) + } else { + sleepCarousel(sessions: sessions) + } + summaryCard(daySummary, fallback: dayFallback) + } + } + + /// One session's stack: Hero + hypnogram VisualizationCard + stage cards. + @ViewBuilder + private func sessionPage(_ s: SleepSummary) -> some View { + let score = SleepScore.calculate(s) + SleepHeroCardView( + label: SleepInsights.rangeHeroLabel[.day] ?? "Last Sleep", + value: SleepFormat.duration(s.session.totalMinutes), + support: "\(SleepFormat.clockTime(s.session.startAt)) to \(SleepFormat.clockTime(s.session.endAt))", + score: score.score, + scoreLabel: score.label.rawValue + ) + VisualizationCard(eyebrow: "Stages", title: "Sleep architecture", legend: true) { + SleepHypnogramView(blocks: s.blocks, totalMin: s.session.totalMinutes, startTs: s.session.startAt) + } + SleepStageSummaryCardsView( + deep: SleepFormat.duration(s.deepMinutes), + light: SleepFormat.duration(s.lightMinutes), + awake: SleepFormat.duration(s.awakeMinutes) + ) + } + + /// Horizontal paged carousel across multiple sleep sessions in one day. + /// Sizes to the tallest visible page (no fixed height) and shows a dot row. + @ViewBuilder + private func sleepCarousel(sessions: [SleepSummary]) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(Array(sessions.enumerated()), id: \.element.session.id) { idx, s in + VStack(spacing: 16) { + Text("\(idx + 1) of \(sessions.count) · \(SleepFormat.clockTime(s.session.startAt))–\(SleepFormat.clockTime(s.session.endAt))") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + .kerning(0.5) + .frame(maxWidth: .infinity, alignment: .leading) + sessionPage(s) + } + .containerRelativeFrame(.horizontal) + .id(idx) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: $scrolledSleepPage) + .onChange(of: scrolledSleepPage) { _, newValue in + if let newValue { selectedSleepPage = newValue } + } + + // Page indicator dots. + HStack(spacing: 8) { + ForEach(sessions.indices, id: \.self) { i in + Circle() + .fill(i == selectedSleepPage ? PulseColors.accent : PulseColors.textSecondary.opacity(0.3)) + .frame(width: 7, height: 7) + .animation(reduceMotion ? nil : PulseMotion.spring, value: selectedSleepPage) + } } + .frame(maxWidth: .infinity) + .padding(.top, 4) } // MARK: Aggregate diff --git a/PulseLoopTests/SleepServiceTests.swift b/PulseLoopTests/SleepServiceTests.swift index 779906d6..8b11e6ca 100644 --- a/PulseLoopTests/SleepServiceTests.swift +++ b/PulseLoopTests/SleepServiceTests.swift @@ -217,4 +217,202 @@ final class SleepServiceTests: XCTestCase { XCTAssertEqual(sessions.count, 1) XCTAssertEqual(sessions.first?.id, rich.id, "tie on totalMinutes should keep the session with more blocks") } + + // MARK: - Sleep-session segmentation (issue #59) + + /// Build a detached `SleepStageBlock` for the pure `segment` tests. `segment` only + /// reads `startAt`/`durationMinutes`, so `sessionId`/`startMinute` are placeholders. + private func block(_ start: Date, minutes: Int, stage: SleepStage = .light) -> SleepStageBlock { + SleepStageBlock(sessionId: UUID(), startAt: start, startMinute: 0, durationMinutes: minutes, stage: stage) + } + + private func at(_ hour: Int, _ minute: Int = 0, dayOffset: Int = 0) -> Date { + let base = TestSupport.day(dayOffset) + return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: base) ?? base + } + + // 1. SleepSegmentation.segment — pure, no context + + func testSegmentEmptyReturnsEmpty() { + XCTAssertTrue(SleepSegmentation.segment([]).isEmpty) + } + + func testSegmentContiguousBlocksStayOneGroup() { + // Block2 starts exactly when block1 ends (00:00 + 30m = 00:30) -> zero gap -> one group. + let b1 = block(at(0, 0), minutes: 30) + let b2 = block(at(0, 30), minutes: 30) + let groups = SleepSegmentation.segment([b1, b2]) + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.count, 2) + } + + func testSegmentLargeGapSplitsIntoTwoGroups() { + // Block1 [00:00, 60m] ends 01:00; block2 starts 14:00 -> ~13h gap >= 60m -> two groups. + let b1 = block(at(0, 0), minutes: 60) + let b2 = block(at(14, 0), minutes: 30) + let groups = SleepSegmentation.segment([b1, b2]) + XCTAssertEqual(groups.count, 2) + XCTAssertEqual(groups[0].count, 1) + XCTAssertEqual(groups[1].count, 1) + } + + func testSegmentShortAwakeningStaysOneGroup() { + // Block1 [23:00, 120m] ends 01:00; block2 starts 01:30 -> 30m gap < 60m -> one group. + let b1 = block(at(23, 0, dayOffset: -1), minutes: 120) + let b2 = block(at(1, 30), minutes: 60) + let groups = SleepSegmentation.segment([b1, b2]) + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.count, 2) + } + + func testSegmentThreeSleepsSplitIntoThreeGroupsInOrder() { + let b1 = block(at(1, 0), minutes: 60) // ends 02:00 + let b2 = block(at(9, 0), minutes: 30) // gap 7h -> split; ends 09:30 + let b3 = block(at(15, 0), minutes: 30) // gap ~5.5h -> split + let groups = SleepSegmentation.segment([b1, b2, b3]) + XCTAssertEqual(groups.count, 3) + // Chronological order preserved. + XCTAssertEqual(groups[0].first?.startAt, b1.startAt) + XCTAssertEqual(groups[1].first?.startAt, b2.startAt) + XCTAssertEqual(groups[2].first?.startAt, b3.startAt) + } + + func testSegmentUnsortedInputStillGroupsCorrectly() { + let bNight1 = block(at(23, 0, dayOffset: -1), minutes: 60) // ends 00:00 + let bNight2 = block(at(0, 0), minutes: 60) // contiguous with night1 + let bNap = block(at(14, 0), minutes: 30) // >60m gap -> own group + // Pass out of chronological order. + let groups = SleepSegmentation.segment([bNap, bNight2, bNight1]) + XCTAssertEqual(groups.count, 2) + XCTAssertEqual(groups[0].count, 2, "the two contiguous night blocks group together") + XCTAssertEqual(groups[0].first?.startAt, bNight1.startAt, "groups come out sorted") + XCTAssertEqual(groups[1].count, 1) + XCTAssertEqual(groups[1].first?.startAt, bNap.startAt) + } + + // 2. SleepService.reconcileWakingDay — in-memory context + + /// Insert ONE session whose blocks form a night run + a nap run separated by a >60m + /// gap under a single row, then reconcile the waking day. Returns the (day, expected bounds). + @discardableResult + private func seedNightPlusNapSession(into context: ModelContext) -> (day: Date, nightStart: Date, nightEnd: Date, napStart: Date, napEnd: Date) { + let day = TestSupport.day(0) + let nightStart = at(1, 0) // 01:00 + let napStart = at(14, 0) // 14:00, >60m after the night ends + let session = SleepSession(date: day, startAt: nightStart, endAt: at(15, 0), totalMinutes: 0) + context.insert(session) + // Night: 3 contiguous 60-min blocks -> 01:00..04:00 + for i in 0..<3 { + let s = Calendar.current.date(byAdding: .minute, value: i * 60, to: nightStart)! + context.insert(SleepStageBlock(sessionId: session.id, startAt: s, startMinute: i * 60, durationMinutes: 60, stage: .deep)) + } + // Nap: 2 contiguous 30-min blocks -> 14:00..15:00 + for i in 0..<2 { + let s = Calendar.current.date(byAdding: .minute, value: i * 30, to: napStart)! + context.insert(SleepStageBlock(sessionId: session.id, startAt: s, startMinute: i * 30, durationMinutes: 30, stage: .light)) + } + try? context.save() + let nightEnd = Calendar.current.date(byAdding: .minute, value: 180, to: nightStart)! // 04:00 + let napEnd = Calendar.current.date(byAdding: .minute, value: 60, to: napStart)! // 15:00 + return (day, nightStart, nightEnd, napStart, napEnd) + } + + private func sessions(on day: Date, in context: ModelContext) -> [SleepSession] { + let cal = Calendar.current + return ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { cal.isDate($0.date, inSameDayAs: day) } + .sorted { $0.startAt < $1.startAt } + } + + func testReconcileSplitsNightAndNapIntoTwoSessions() throws { + let context = try TestSupport.makeContext() + let seed = seedNightPlusNapSession(into: context) + + SleepService.reconcileWakingDay(dateKey: seed.day, context: context) + + let rows = sessions(on: seed.day, in: context) + XCTAssertEqual(rows.count, 2, "night + nap separated by a >60m gap split into two rows") + + let night = rows[0] + let nap = rows[1] + + // Each row's blocks all belong to it, and the first block of each is rebased to minute 0. + for row in rows { + let blocks = SleepRepository.blocks(sessionId: row.id, context: context) + XCTAssertFalse(blocks.isEmpty) + XCTAssertTrue(blocks.allSatisfy { $0.sessionId == row.id }) + XCTAssertEqual(blocks.map { $0.startMinute }.min(), 0, "first block of each session rebased to startMinute 0") + } + + // Bounds match each segment's first block start / last block end. + XCTAssertEqual(night.startAt, seed.nightStart) + XCTAssertEqual(night.endAt, seed.nightEnd) + XCTAssertEqual(nap.startAt, seed.napStart) + XCTAssertEqual(nap.endAt, seed.napEnd) + + // Own spans, not one merged ~14h span. + XCTAssertEqual(night.totalMinutes, 180, "night span 01:00..04:00") + XCTAssertEqual(nap.totalMinutes, 60, "nap span 14:00..15:00") + } + + func testReconcileIsIdempotent() throws { + let context = try TestSupport.makeContext() + let seed = seedNightPlusNapSession(into: context) + + SleepService.reconcileWakingDay(dateKey: seed.day, context: context) + let first = sessions(on: seed.day, in: context) + XCTAssertEqual(first.count, 2) + + SleepService.reconcileWakingDay(dateKey: seed.day, context: context) + let second = sessions(on: seed.day, in: context) + XCTAssertEqual(second.count, 2, "re-running does not churn into 3+ or collapse to 1") + XCTAssertEqual(second[0].startAt, seed.nightStart) + XCTAssertEqual(second[0].endAt, seed.nightEnd) + XCTAssertEqual(second[1].startAt, seed.napStart) + XCTAssertEqual(second[1].endAt, seed.napEnd) + } + + func testReconcileSingleContiguousNightStaysOneSession() throws { + let context = try TestSupport.makeContext() + let day = TestSupport.day(0) + let start = at(1, 0) + let session = SleepSession(date: day, startAt: start, endAt: at(7, 0), totalMinutes: 0) + context.insert(session) + // 6 contiguous 60-min blocks: 01:00..07:00, no gap >= 60m. + for i in 0..<6 { + let s = Calendar.current.date(byAdding: .minute, value: i * 60, to: start)! + context.insert(SleepStageBlock(sessionId: session.id, startAt: s, startMinute: i * 60, durationMinutes: 60, stage: .light)) + } + try? context.save() + + SleepService.reconcileWakingDay(dateKey: day, context: context) + + let rows = sessions(on: day, in: context) + XCTAssertEqual(rows.count, 1, "a single contiguous night must not spuriously split") + XCTAssertEqual(rows.first?.totalMinutes, 360, "01:00..07:00 span") + } + + // 3. SleepService.migrateSleepSessionSegmentsIfNeeded + + func testMigrateSleepSessionSegmentsSplitsLegacyMergedRow() throws { + let context = try TestSupport.makeContext() + let key = "sleepSessionSegment.v1" + // Capture and reset the gate so the migration runs; restore global state afterwards. + let priorGate = UserDefaults.standard.bool(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + defer { + if priorGate { UserDefaults.standard.set(true, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + + // Seed a legacy merged row: a night + a nap both under ONE session, separated by a >60m gap. + seedNightPlusNapSession(into: context) + let day = TestSupport.day(0) + XCTAssertEqual(sessions(on: day, in: context).count, 1, "starts as one merged row") + + SleepService.migrateSleepSessionSegmentsIfNeeded(context: context) + + XCTAssertEqual(sessions(on: day, in: context).count, 2, "migration re-segmented the merged row into night + nap") + XCTAssertTrue(UserDefaults.standard.bool(forKey: key), "gate is set true after the one-time pass") + } } From a169e07fc260e20e6181b95cb6859518f76e68e7 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:49:06 +1000 Subject: [PATCH 2/6] fix(sleep): stable session identity + change-gated reconcile + nap-aware aggregates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review of this branch surfaced four issues; fix them before merge: - reconcileWakingDay reused day-rows by positional index after sorting by startAt, so an out-of-order sync (a nap arriving before the night it precedes) silently flipped a row's identity — misattributing anything keyed to that SleepSession.id (e.g. a coach summary). Match each segment to the existing row whose prior time-range overlaps it instead. - reconcileWakingDay inserted a DerivedUpdateRow (and bumped syncedAt) per segment on every call, unconditionally — re-syncing an unchanged day spammed the audit table. Emit the change signal only when a row's bounds or block set actually changed. - durationConsistency computed variance over non-collapsed sessions against a collapsed mean, so a nap spuriously inflated the SD and flipped the "Variable nights" chip. Collapse first. - aggregateCoach and the aggregate hero counted naps as extra "nights tracked" while their averages collapsed by day. Collapse the count too. --- PulseLoop/Services/PulseServices.swift | 107 +++++++++++++++++-------- PulseLoop/Services/SleepInsights.swift | 8 +- PulseLoop/Views/SleepView.swift | 4 +- 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index f21d454f..662a8c6f 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -739,66 +739,107 @@ enum SleepService { } /// Re-derive one waking day's sessions from its raw stage blocks: split by - /// `SleepSegmentation`, then reconcile the SwiftData rows — reuse existing rows in - /// time order, insert any missing, delete extras — re-point every block to its - /// segment and recompute session bounds. Idempotent: re-running on the same blocks - /// yields the same rows. Called after each timeline packet and from the one-time - /// migration. Does not save; the caller owns the transaction boundary. + /// `SleepSegmentation`, then reconcile the SwiftData rows — match each segment to the + /// existing row whose prior time-range overlaps it (so identity is stable even when a + /// nap syncs before the night it precedes), insert rows for unmatched segments, delete + /// leftover empty rows, re-point every block to its segment, and recompute bounds. A + /// change signal (`DerivedUpdateRow` + `syncedAt` bump) is emitted only when a row's + /// bounds or block set actually changed, so re-syncing an unchanged day is a true no-op. + /// Idempotent. Does not save; the caller owns the transaction boundary. static func reconcileWakingDay(dateKey: Date, context: ModelContext) { let calendar = Calendar.current let day = calendar.startOfDay(for: dateKey) + let now = Date() let allSessions = (try? context.fetch(FetchDescriptor())) ?? [] - let daySessions = allSessions - .filter { calendar.isDate($0.date, inSameDayAs: day) } - .sorted { $0.startAt < $1.startAt } + let daySessions = allSessions.filter { calendar.isDate($0.date, inSameDayAs: day) } guard !daySessions.isEmpty else { return } let daySessionIds = Set(daySessions.map { $0.id }) let dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) .filter { daySessionIds.contains($0.sessionId) } - let segments = SleepSegmentation.segment(dayBlocks) + let rawSegments = SleepSegmentation.segment(dayBlocks) // No blocks left on this day — drop the empty rows entirely. - guard !segments.isEmpty else { + guard !rawSegments.isEmpty else { for row in daySessions { context.delete(row) } return } - for (index, segment) in segments.enumerated() { - let sorted = segment.sorted { $0.startAt < $1.startAt } - guard let segStart = sorted.first?.startAt else { continue } - let segEnd = sorted - .map { $0.startAt.addingTimeInterval(Double($0.durationMinutes) * 60) } - .max() ?? segStart + // Snapshot each row's PRIOR bounds + owned block ids before any mutation, for both + // identity matching (#9) and change detection (#8). + let priorBlocksByRow = Dictionary(grouping: dayBlocks, by: { $0.sessionId }) + struct Prior { let start: Date; let end: Date; let blockIDs: Set } + var prior: [UUID: Prior] = [:] + for row in daySessions { + prior[row.id] = Prior(start: row.startAt, end: row.endAt, + blockIDs: Set((priorBlocksByRow[row.id] ?? []).map { $0.id })) + } + + struct Segment { let blocks: [SleepStageBlock]; let start: Date; let end: Date } + let segments: [Segment] = rawSegments.compactMap { seg in + let sorted = seg.sorted { $0.startAt < $1.startAt } + guard let start = sorted.first?.startAt else { return nil } + let end = sorted.map { $0.startAt.addingTimeInterval(Double($0.durationMinutes) * 60) }.max() ?? start + return Segment(blocks: sorted, start: start, end: end) + } + func overlap(_ a0: Date, _ a1: Date, _ b0: Date, _ b1: Date) -> TimeInterval { + max(0, min(a1, b1).timeIntervalSince(max(a0, b0))) + } + + // Greedily match each segment to the best-overlapping unused row; a row also matches when it + // contains the segment's start (covers a freshly-created zero-length container row). + var available = daySessions + var matched: [(segment: Segment, row: SleepSession?)] = [] + for segment in segments { + let best = available.enumerated().max { l, r in + overlap(segment.start, segment.end, prior[l.element.id]!.start, prior[l.element.id]!.end) + < overlap(segment.start, segment.end, prior[r.element.id]!.start, prior[r.element.id]!.end) + } + if let best, let p = prior[best.element.id], + overlap(segment.start, segment.end, p.start, p.end) > 0 || (segment.start...segment.end).contains(p.start) { + available.remove(at: best.offset) + matched.append((segment, best.element)) + } else { + matched.append((segment, nil)) + } + } + + for (segment, existing) in matched { let row: SleepSession - if index < daySessions.count { - row = daySessions[index] + let isNew: Bool + if let existing { + row = existing; isNew = false } else { - row = SleepSession(date: day, startAt: segStart, endAt: segEnd, totalMinutes: 0, syncedAt: Date()) - context.insert(row) + row = SleepSession(date: day, startAt: segment.start, endAt: segment.end, totalMinutes: 0, syncedAt: now) + context.insert(row); isNew = true } - for block in sorted { + for block in segment.blocks { block.sessionId = row.id - block.startMinute = max(0, Int(block.startAt.timeIntervalSince(segStart) / 60)) + block.startMinute = max(0, Int(block.startAt.timeIntervalSince(segment.start) / 60)) } + let newTotal = max(0, Int(segment.end.timeIntervalSince(segment.start) / 60)) + let newBlockIDs = Set(segment.blocks.map { $0.id }) + let p = prior[row.id] + let changed = isNew || p == nil + || p!.start != segment.start || p!.end != segment.end || p!.blockIDs != newBlockIDs + row.date = day - row.startAt = segStart - row.endAt = segEnd - row.totalMinutes = max(0, Int(segEnd.timeIntervalSince(segStart) / 60)) - row.syncedAt = Date() - row.updatedAt = Date() - context.insert(DerivedUpdateRow(kind: "sleep_timeline", entityType: "sleep_session", entityId: row.id.uuidString)) + row.startAt = segment.start + row.endAt = segment.end + row.totalMinutes = newTotal + if changed { + row.syncedAt = now + row.updatedAt = now + context.insert(DerivedUpdateRow(kind: "sleep_timeline", entityType: "sleep_session", entityId: row.id.uuidString)) + } } - // Every block was re-pointed to a segment row above, so any rows beyond the - // segment count are now empty — remove them. - if daySessions.count > segments.count { - for row in daySessions[segments.count...] { context.delete(row) } - } + // Rows not matched to any segment had all their blocks re-pointed away — delete them. + for row in available { context.delete(row) } } /// One-time re-segmentation of existing sleep sessions that were persisted before diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index 9fba08b0..d75a91f7 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -244,6 +244,10 @@ enum SleepInsights { /// Population standard deviation of nightly durations (minutes). private static func durationConsistency(_ valid: [SleepSummary]) -> Double? { + // Collapse naps into their day first so the variance is computed over the same + // per-day population that `averageDuration` produces the mean from (a nap counted + // as its own point against the collapsed mean spuriously inflated the SD). + let valid = collapseByDay(valid) guard valid.count >= 2, let avg = averageDuration(valid) else { return nil } let variance = valid.reduce(0.0) { $0 + pow(Double($1.session.totalMinutes - avg), 2) } / Double(valid.count) return sqrt(variance) @@ -309,7 +313,9 @@ enum SleepInsights { ) static func aggregateCoach(range: SleepRangeKey, sessions: [SleepSummary], expectedNights: Int, goalMin: Int?) -> SleepCoach { - let valid = validSessions(sessions) + // 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). + let valid = collapseByDay(validSessions(sessions)) let avgMin = averageDuration(valid) let periodWord = range == .week ? "week" : range == .month ? "month" : "year" diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index a72e5fc2..befc053b 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -169,7 +169,9 @@ struct SleepView: View { @ViewBuilder private func aggregateView(summary: SleepRangeSummary, goalMin: Int?) -> some View { - let valid = SleepInsights.validSessions(summary.sessions) + // Collapse naps into their day so the hero's "N of M nights tracked" counts distinct nights, + // consistent with the (already-collapsing) averages and coach copy. + let valid = SleepInsights.collapseByDay(SleepInsights.validSessions(summary.sessions)) let enough = valid.count >= 2 let avgMin = SleepInsights.averageDuration(valid) let avgScore = SleepInsights.averageScore(valid) From b874e13a84593cb80f4859e5fe7bf5582c66c9bb Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 15 Jul 2026 20:30:32 -0400 Subject: [PATCH 3/6] Move sleep segmentation into SleepSegmentation.swift and gate reconcile's change signal on block re-point --- PulseLoop/Services/PulseServices.swift | 157 ------------------ PulseLoop/Services/SleepSegmentation.swift | 182 +++++++++++++++++++++ 2 files changed, 182 insertions(+), 157 deletions(-) create mode 100644 PulseLoop/Services/SleepSegmentation.swift diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 662a8c6f..13e39aca 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -559,42 +559,6 @@ extension Calendar { } } -/// Splits a waking-day's sleep into distinct sessions — the main night vs. daytime -/// naps — so each can surface as its own carousel page (issue #59). Pure and -/// testable; the SwiftData-facing reconciliation lives in `SleepService`. -enum SleepSegmentation { - /// A run of >= this many minutes with no recorded sleep data marks a boundary - /// between two sessions. The ring writes a contiguous per-minute block for each - /// timeline packet, so short mid-night awakenings stay in one session while a nap - /// hours after the night (a genuine gap in the blocks) splits into its own. - static let sessionGapMinutes = 60 - - /// Group blocks into chronological sessions, splitting wherever the gap from one - /// block's end to the next block's start is >= `sessionGapMinutes`. Input need not - /// be sorted. Returns time-ordered groups; empty in -> empty out. - static func segment(_ blocks: [SleepStageBlock]) -> [[SleepStageBlock]] { - let sorted = blocks.sorted { $0.startAt < $1.startAt } - guard let firstBlock = sorted.first else { return [] } - let gapSeconds = Double(sessionGapMinutes) * 60 - var groups: [[SleepStageBlock]] = [] - var current: [SleepStageBlock] = [firstBlock] - var prevEnd = firstBlock.startAt.addingTimeInterval(Double(firstBlock.durationMinutes) * 60) - for block in sorted.dropFirst() { - if block.startAt.timeIntervalSince(prevEnd) >= gapSeconds { - groups.append(current) - current = [block] - } else { - current.append(block) - } - // `max` guards against nested/overlapping blocks pulling the running end backwards. - let end = block.startAt.addingTimeInterval(Double(block.durationMinutes) * 60) - prevEnd = max(prevEnd, end) - } - groups.append(current) - return groups - } -} - @MainActor enum SleepService { static func latestSleep(context: ModelContext) -> SleepSummary? { @@ -738,127 +702,6 @@ enum SleepService { try? context.save() } - /// Re-derive one waking day's sessions from its raw stage blocks: split by - /// `SleepSegmentation`, then reconcile the SwiftData rows — match each segment to the - /// existing row whose prior time-range overlaps it (so identity is stable even when a - /// nap syncs before the night it precedes), insert rows for unmatched segments, delete - /// leftover empty rows, re-point every block to its segment, and recompute bounds. A - /// change signal (`DerivedUpdateRow` + `syncedAt` bump) is emitted only when a row's - /// bounds or block set actually changed, so re-syncing an unchanged day is a true no-op. - /// Idempotent. Does not save; the caller owns the transaction boundary. - static func reconcileWakingDay(dateKey: Date, context: ModelContext) { - let calendar = Calendar.current - let day = calendar.startOfDay(for: dateKey) - let now = Date() - - let allSessions = (try? context.fetch(FetchDescriptor())) ?? [] - let daySessions = allSessions.filter { calendar.isDate($0.date, inSameDayAs: day) } - guard !daySessions.isEmpty else { return } - - let daySessionIds = Set(daySessions.map { $0.id }) - let dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) - .filter { daySessionIds.contains($0.sessionId) } - - let rawSegments = SleepSegmentation.segment(dayBlocks) - - // No blocks left on this day — drop the empty rows entirely. - guard !rawSegments.isEmpty else { - for row in daySessions { context.delete(row) } - return - } - - // Snapshot each row's PRIOR bounds + owned block ids before any mutation, for both - // identity matching (#9) and change detection (#8). - let priorBlocksByRow = Dictionary(grouping: dayBlocks, by: { $0.sessionId }) - struct Prior { let start: Date; let end: Date; let blockIDs: Set } - var prior: [UUID: Prior] = [:] - for row in daySessions { - prior[row.id] = Prior(start: row.startAt, end: row.endAt, - blockIDs: Set((priorBlocksByRow[row.id] ?? []).map { $0.id })) - } - - struct Segment { let blocks: [SleepStageBlock]; let start: Date; let end: Date } - let segments: [Segment] = rawSegments.compactMap { seg in - let sorted = seg.sorted { $0.startAt < $1.startAt } - guard let start = sorted.first?.startAt else { return nil } - let end = sorted.map { $0.startAt.addingTimeInterval(Double($0.durationMinutes) * 60) }.max() ?? start - return Segment(blocks: sorted, start: start, end: end) - } - - func overlap(_ a0: Date, _ a1: Date, _ b0: Date, _ b1: Date) -> TimeInterval { - max(0, min(a1, b1).timeIntervalSince(max(a0, b0))) - } - - // Greedily match each segment to the best-overlapping unused row; a row also matches when it - // contains the segment's start (covers a freshly-created zero-length container row). - var available = daySessions - var matched: [(segment: Segment, row: SleepSession?)] = [] - for segment in segments { - let best = available.enumerated().max { l, r in - overlap(segment.start, segment.end, prior[l.element.id]!.start, prior[l.element.id]!.end) - < overlap(segment.start, segment.end, prior[r.element.id]!.start, prior[r.element.id]!.end) - } - if let best, let p = prior[best.element.id], - overlap(segment.start, segment.end, p.start, p.end) > 0 || (segment.start...segment.end).contains(p.start) { - available.remove(at: best.offset) - matched.append((segment, best.element)) - } else { - matched.append((segment, nil)) - } - } - - for (segment, existing) in matched { - let row: SleepSession - let isNew: Bool - if let existing { - row = existing; isNew = false - } else { - row = SleepSession(date: day, startAt: segment.start, endAt: segment.end, totalMinutes: 0, syncedAt: now) - context.insert(row); isNew = true - } - - for block in segment.blocks { - block.sessionId = row.id - block.startMinute = max(0, Int(block.startAt.timeIntervalSince(segment.start) / 60)) - } - let newTotal = max(0, Int(segment.end.timeIntervalSince(segment.start) / 60)) - let newBlockIDs = Set(segment.blocks.map { $0.id }) - let p = prior[row.id] - let changed = isNew || p == nil - || p!.start != segment.start || p!.end != segment.end || p!.blockIDs != newBlockIDs - - row.date = day - row.startAt = segment.start - row.endAt = segment.end - row.totalMinutes = newTotal - if changed { - row.syncedAt = now - row.updatedAt = now - context.insert(DerivedUpdateRow(kind: "sleep_timeline", entityType: "sleep_session", entityId: row.id.uuidString)) - } - } - - // Rows not matched to any segment had all their blocks re-pointed away — delete them. - for row in available { context.delete(row) } - } - - /// One-time re-segmentation of existing sleep sessions that were persisted before - /// per-session splitting: an afternoon nap used to merge into that morning's night - /// (one row spanning ~16 h with a broken hypnogram). Re-derives each waking day into - /// distinct sessions. Idempotent + `UserDefaults`-gated so it runs once, off the - /// render path. Mirrors `migrateSplitSleepSessionsIfNeeded`. - static func migrateSleepSessionSegmentsIfNeeded(context: ModelContext) { - let key = "sleepSessionSegment.v1" - guard !UserDefaults.standard.bool(forKey: key) else { return } - let calendar = Calendar.current - let sessions = (try? context.fetch(FetchDescriptor())) ?? [] - let days = Set(sessions.map { calendar.startOfDay(for: $0.date) }) - for day in days { - reconcileWakingDay(dateKey: day, context: context) - } - try? context.save() - UserDefaults.standard.set(true, forKey: key) - } } @MainActor diff --git a/PulseLoop/Services/SleepSegmentation.swift b/PulseLoop/Services/SleepSegmentation.swift new file mode 100644 index 00000000..ca5ba615 --- /dev/null +++ b/PulseLoop/Services/SleepSegmentation.swift @@ -0,0 +1,182 @@ +import Foundation +import SwiftData + +/// Splits a waking-day's sleep into distinct sessions — the main night vs. daytime +/// naps — so each can surface as its own carousel page (issue #59). Pure and +/// testable; the SwiftData-facing reconciliation lives in `SleepService`. +enum SleepSegmentation { + /// A run of >= this many minutes with no recorded sleep data marks a boundary + /// between two sessions. The ring writes a contiguous per-minute block for each + /// timeline packet, so short mid-night awakenings stay in one session while a nap + /// hours after the night (a genuine gap in the blocks) splits into its own. + static let sessionGapMinutes = 60 + + /// Group blocks into chronological sessions, splitting wherever the gap from one + /// block's end to the next block's start is >= `sessionGapMinutes`. Input need not + /// be sorted. Returns time-ordered groups; empty in -> empty out. + static func segment(_ blocks: [SleepStageBlock]) -> [[SleepStageBlock]] { + let sorted = blocks.sorted { $0.startAt < $1.startAt } + guard let firstBlock = sorted.first else { return [] } + let gapSeconds = Double(sessionGapMinutes) * 60 + var groups: [[SleepStageBlock]] = [] + var current: [SleepStageBlock] = [firstBlock] + var prevEnd = firstBlock.startAt.addingTimeInterval(Double(firstBlock.durationMinutes) * 60) + for block in sorted.dropFirst() { + if block.startAt.timeIntervalSince(prevEnd) >= gapSeconds { + groups.append(current) + current = [block] + } else { + current.append(block) + } + // `max` guards against nested/overlapping blocks pulling the running end backwards. + let end = block.startAt.addingTimeInterval(Double(block.durationMinutes) * 60) + prevEnd = max(prevEnd, end) + } + groups.append(current) + return groups + } +} + +@MainActor +extension SleepService { + /// Re-derive one waking day's sessions from its raw stage blocks: split by + /// `SleepSegmentation`, then reconcile the SwiftData rows — match each segment to the + /// existing row whose prior time-range overlaps it (so identity is stable even when a + /// nap syncs before the night it precedes), insert rows for unmatched segments, delete + /// leftover empty rows, re-point every block to its segment, and recompute bounds. A + /// change signal (`DerivedUpdateRow` + `syncedAt` bump) is emitted only when a row's + /// bounds actually moved or one of its blocks was re-pointed/re-based, so re-syncing an + /// unchanged day is a true no-op. Idempotent. Does not save; the caller owns the + /// transaction boundary. + /// + /// `daySessions`/`dayBlocks` let a caller inject the day's already-in-memory rows and + /// blocks to skip the full-table fetches. `persistSleepTimeline` passes them on the sync + /// hot path (it holds the objects already, including its just-inserted blocks — which a + /// predicated re-fetch couldn't see before the coalesced `save()`); the migration passes + /// per-day slices of a single up-front fetch. When omitted, both are fetched here. + static func reconcileWakingDay(dateKey: Date, context: ModelContext, + daySessions injectedSessions: [SleepSession]? = nil, + dayBlocks injectedBlocks: [SleepStageBlock]? = nil) { + let calendar = Calendar.current + let day = calendar.startOfDay(for: dateKey) + let now = Date() + + let daySessions = injectedSessions ?? ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { calendar.isDate($0.date, inSameDayAs: day) } + guard !daySessions.isEmpty else { return } + + let dayBlocks: [SleepStageBlock] + if let injectedBlocks { + dayBlocks = injectedBlocks + } else { + let daySessionIds = Set(daySessions.map { $0.id }) + dayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { daySessionIds.contains($0.sessionId) } + } + + let rawSegments = SleepSegmentation.segment(dayBlocks) + + // No blocks left on this day — drop the empty rows entirely. + guard !rawSegments.isEmpty else { + for row in daySessions { context.delete(row) } + return + } + + // Snapshot each row's PRIOR bounds before any mutation, for identity matching (#9) + // and bounds-based change detection (#8). Block-set changes are detected per block + // in the assignment loop below rather than from a snapshot, because a caller may hand + // us blocks it just inserted — they'd already look "owned" in a snapshot. + struct Prior { let start: Date; let end: Date } + var prior: [UUID: Prior] = [:] + for row in daySessions { prior[row.id] = Prior(start: row.startAt, end: row.endAt) } + + struct Segment { let blocks: [SleepStageBlock]; let start: Date; let end: Date } + let segments: [Segment] = rawSegments.compactMap { seg in + let sorted = seg.sorted { $0.startAt < $1.startAt } + guard let start = sorted.first?.startAt else { return nil } + let end = sorted.map { $0.startAt.addingTimeInterval(Double($0.durationMinutes) * 60) }.max() ?? start + return Segment(blocks: sorted, start: start, end: end) + } + + func overlap(_ a0: Date, _ a1: Date, _ b0: Date, _ b1: Date) -> TimeInterval { + max(0, min(a1, b1).timeIntervalSince(max(a0, b0))) + } + + // Greedily match each segment to the best-overlapping unused row; a row also matches when it + // contains the segment's start (covers a freshly-created zero-length container row). + var available = daySessions + var matched: [(segment: Segment, row: SleepSession?)] = [] + for segment in segments { + let best = available.enumerated().max { l, r in + overlap(segment.start, segment.end, prior[l.element.id]!.start, prior[l.element.id]!.end) + < overlap(segment.start, segment.end, prior[r.element.id]!.start, prior[r.element.id]!.end) + } + if let best, let p = prior[best.element.id], + overlap(segment.start, segment.end, p.start, p.end) > 0 || (segment.start...segment.end).contains(p.start) { + available.remove(at: best.offset) + matched.append((segment, best.element)) + } else { + matched.append((segment, nil)) + } + } + + for (segment, existing) in matched { + let row: SleepSession + var changed: Bool + if let existing { + row = existing; changed = false + } else { + row = SleepSession(date: day, startAt: segment.start, endAt: segment.end, totalMinutes: 0, syncedAt: now) + context.insert(row); changed = true + } + + for block in segment.blocks { + let newMinute = max(0, Int(block.startAt.timeIntervalSince(segment.start) / 60)) + // A re-pointed block (moved between sessions) or a re-based one (its minute + // offset shifted) means this row's contents changed — including brand-new + // blocks, which `persistSleepTimeline` inserts with `startMinute == 0`. + if block.sessionId != row.id || block.startMinute != newMinute { changed = true } + block.sessionId = row.id + block.startMinute = newMinute + } + if let p = prior[row.id], p.start != segment.start || p.end != segment.end { changed = true } + + row.date = day + row.startAt = segment.start + row.endAt = segment.end + row.totalMinutes = max(0, Int(segment.end.timeIntervalSince(segment.start) / 60)) + if changed { + row.syncedAt = now + row.updatedAt = now + context.insert(DerivedUpdateRow(kind: "sleep_timeline", entityType: "sleep_session", entityId: row.id.uuidString)) + } + } + + // Rows not matched to any segment had all their blocks re-pointed away — delete them. + for row in available { context.delete(row) } + } + + /// One-time re-segmentation of existing sleep sessions that were persisted before + /// per-session splitting: an afternoon nap used to merge into that morning's night + /// (one row spanning ~16 h with a broken hypnogram). Re-derives each waking day into + /// distinct sessions. Idempotent + `UserDefaults`-gated so it runs once, off the + /// render path. Mirrors `migrateSplitSleepSessionsIfNeeded`. + static func migrateSleepSessionSegmentsIfNeeded(context: ModelContext) { + let key = "sleepSessionSegment.v1" + guard !UserDefaults.standard.bool(forKey: key) else { return } + let calendar = Calendar.current + // Fetch every session and block once, then reconcile each day off in-memory slices — + // waking days are disjoint, so a day's reconcile never touches another's rows, keeping + // the up-front snapshot valid across the loop (vs. a full-table scan per day). + let sessions = (try? context.fetch(FetchDescriptor())) ?? [] + let blocksBySession = Dictionary(grouping: (try? context.fetch(FetchDescriptor())) ?? [], + by: { $0.sessionId }) + let sessionsByDay = Dictionary(grouping: sessions, by: { calendar.startOfDay(for: $0.date) }) + for (day, daySessions) in sessionsByDay { + let dayBlocks = daySessions.flatMap { blocksBySession[$0.id] ?? [] } + reconcileWakingDay(dateKey: day, context: context, daySessions: daySessions, dayBlocks: dayBlocks) + } + try? context.save() + UserDefaults.standard.set(true, forKey: key) + } +} From b0cc7efedc4a666000a886c4cbece302f1d65664 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 15 Jul 2026 20:30:32 -0400 Subject: [PATCH 4/6] Pass the day's in-memory rows and blocks into reconcile from persistSleepTimeline to skip full-table scans --- PulseLoop/Events/PulseEventBus.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index cbf9569a..5bf45980 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -468,10 +468,14 @@ final class EventPersistenceSubscriber { return new }() - let daySessionIds = Set(daySessions.map { $0.id }).union([container.id]) - var existingStarts = Set(((try? context.fetch(FetchDescriptor())) ?? []) + // Rows to hand `reconcileWakingDay` (a just-created container isn't in `daySessions` yet). + let sessionsForDay = daySessions.contains(where: { $0.id == container.id }) ? daySessions : daySessions + [container] + + let daySessionIds = Set(sessionsForDay.map { $0.id }) + let existingDayBlocks = ((try? context.fetch(FetchDescriptor())) ?? []) .filter { daySessionIds.contains($0.sessionId) } - .map { $0.startAt }) + var existingStarts = Set(existingDayBlocks.map { $0.startAt }) + var newBlocks: [SleepStageBlock] = [] var offset = 0 while offset < stages.count { @@ -482,12 +486,18 @@ final class EventPersistenceSubscriber { } let blockStart = calendar.date(byAdding: .minute, value: offset, to: start) ?? start if !existingStarts.contains(blockStart) { - context.insert(SleepStageBlock(sessionId: container.id, startAt: blockStart, startMinute: 0, durationMinutes: duration, stage: stage)) + let block = SleepStageBlock(sessionId: container.id, startAt: blockStart, startMinute: 0, durationMinutes: duration, stage: stage) + context.insert(block) + newBlocks.append(block) existingStarts.insert(blockStart) } offset += duration } - SleepService.reconcileWakingDay(dateKey: dateKey, context: context) + // Hand the in-memory rows + blocks (incl. the ones just inserted, unsaved under the + // coalesced flush) straight to reconcile so it needn't re-scan the whole store. + SleepService.reconcileWakingDay(dateKey: dateKey, context: context, + daySessions: sessionsForDay, + dayBlocks: existingDayBlocks + newBlocks) } } From 4fc64ba4b32090580cd05dcfee301c50c7055537 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 15 Jul 2026 20:30:32 -0400 Subject: [PATCH 5/6] Reset the sleep carousel page when the day's sessions change --- PulseLoop/Views/SleepView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index befc053b..369c3011 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -151,6 +151,12 @@ struct SleepView: View { .onChange(of: scrolledSleepPage) { _, newValue in if let newValue { selectedSleepPage = newValue } } + // Reset to the first page whenever the day's session set changes (and on appear), so a + // stale index can't linger past the dots when a different day or fewer sessions load. + .onChange(of: sessions.map(\.session.id), initial: true) { _, _ in + selectedSleepPage = 0 + scrolledSleepPage = 0 + } // Page indicator dots. HStack(spacing: 8) { From ee4599b0531841c9e24e7405471eafa9fdc07b9c Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 15 Jul 2026 20:30:32 -0400 Subject: [PATCH 6/6] Use a struct for the night+nap test seed and cover session-identity and change-gated reconcile --- PulseLoopTests/SleepServiceTests.swift | 73 +++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/PulseLoopTests/SleepServiceTests.swift b/PulseLoopTests/SleepServiceTests.swift index 8b11e6ca..cc18f02a 100644 --- a/PulseLoopTests/SleepServiceTests.swift +++ b/PulseLoopTests/SleepServiceTests.swift @@ -292,10 +292,19 @@ final class SleepServiceTests: XCTestCase { // 2. SleepService.reconcileWakingDay — in-memory context + /// The seeded day and each segment's expected bounds, returned by `seedNightPlusNapSession`. + private struct NightNapSeed { + let day: Date + let nightStart: Date + let nightEnd: Date + let napStart: Date + let napEnd: Date + } + /// Insert ONE session whose blocks form a night run + a nap run separated by a >60m /// gap under a single row, then reconcile the waking day. Returns the (day, expected bounds). @discardableResult - private func seedNightPlusNapSession(into context: ModelContext) -> (day: Date, nightStart: Date, nightEnd: Date, napStart: Date, napEnd: Date) { + private func seedNightPlusNapSession(into context: ModelContext) -> NightNapSeed { let day = TestSupport.day(0) let nightStart = at(1, 0) // 01:00 let napStart = at(14, 0) // 14:00, >60m after the night ends @@ -314,7 +323,7 @@ final class SleepServiceTests: XCTestCase { try? context.save() let nightEnd = Calendar.current.date(byAdding: .minute, value: 180, to: nightStart)! // 04:00 let napEnd = Calendar.current.date(byAdding: .minute, value: 60, to: napStart)! // 15:00 - return (day, nightStart, nightEnd, napStart, napEnd) + return NightNapSeed(day: day, nightStart: nightStart, nightEnd: nightEnd, napStart: napStart, napEnd: napEnd) } private func sessions(on day: Date, in context: ModelContext) -> [SleepSession] { @@ -415,4 +424,64 @@ final class SleepServiceTests: XCTestCase { XCTAssertEqual(sessions(on: day, in: context).count, 2, "migration re-segmented the merged row into night + nap") XCTAssertTrue(UserDefaults.standard.bool(forKey: key), "gate is set true after the one-time pass") } + + // 4. Identity stability + change-gating (regression cover for the pre-merge review fixes) + + private func sleepTimelineUpdateCount(in context: ModelContext) -> Int { + ((try? context.fetch(FetchDescriptor())) ?? []) + .filter { $0.kind == "sleep_timeline" } + .count + } + + /// A nap that syncs before the night it precedes lands under the day's earliest (container) + /// row, exactly as `persistSleepTimeline` would attach it. Reconcile must keep that row's + /// identity on the nap segment and spin the night into a fresh row — not flip the id onto the + /// night (which positional, sort-by-startAt matching used to do, misattributing anything keyed + /// to the SleepSession.id). + func testReconcileKeepsSessionIdentityWhenNightSyncsAfterNap() throws { + let context = try TestSupport.makeContext() + let day = TestSupport.day(0) + + // Nap synced first: a single row at 14:00–15:00. + let napStart = at(14, 0) + let napRow = SleepSession(date: day, startAt: napStart, endAt: at(15, 0), totalMinutes: 60) + context.insert(napRow) + let napId = napRow.id + for i in 0..<2 { + let s = Calendar.current.date(byAdding: .minute, value: i * 30, to: napStart)! + context.insert(SleepStageBlock(sessionId: napId, startAt: s, startMinute: i * 30, durationMinutes: 30, stage: .light)) + } + // The night arrives later and attaches to that container row, like `persistSleepTimeline`. + let nightStart = at(1, 0) + for i in 0..<3 { + let s = Calendar.current.date(byAdding: .minute, value: i * 60, to: nightStart)! + context.insert(SleepStageBlock(sessionId: napId, startAt: s, startMinute: 0, durationMinutes: 60, stage: .deep)) + } + try? context.save() + + SleepService.reconcileWakingDay(dateKey: day, context: context) + + let rows = sessions(on: day, in: context) // sorted by startAt + XCTAssertEqual(rows.count, 2) + let night = rows[0] + let nap = rows[1] + XCTAssertEqual(nap.id, napId, "the pre-existing row keeps its id on the overlapping nap segment") + XCTAssertNotEqual(night.id, napId, "the night gets a fresh row rather than stealing the nap's id") + XCTAssertEqual(nap.startAt, napStart) + XCTAssertEqual(night.startAt, nightStart) + } + + /// Re-syncing an unchanged day must not spam the audit table: the second reconcile touches no + /// bounds and re-points no block, so it emits zero new `DerivedUpdateRow`s. + func testReconcileEmitsNoChangeSignalOnUnchangedResync() throws { + let context = try TestSupport.makeContext() + let seed = seedNightPlusNapSession(into: context) + + SleepService.reconcileWakingDay(dateKey: seed.day, context: context) + let afterFirst = sleepTimelineUpdateCount(in: context) + XCTAssertGreaterThan(afterFirst, 0, "the first reconcile splits the day and signals the change") + + SleepService.reconcileWakingDay(dateKey: seed.day, context: context) + XCTAssertEqual(sleepTimelineUpdateCount(in: context), afterFirst, "an unchanged re-sync is a true no-op") + } }