diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 6f5d333..5bf4598 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -455,19 +455,28 @@ 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 } - } + // 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) } + var existingStarts = Set(existingDayBlocks.map { $0.startAt }) + var newBlocks: [SleepStageBlock] = [] - var existingStarts = Set(blocks(for: session.id).map { $0.startAt }) var offset = 0 while offset < stages.count { let stage = stages[offset] @@ -477,26 +486,18 @@ 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)) + 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 } - 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)) + // 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) } } diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index d702797..f02baf7 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 8add881..13e39ac 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -572,9 +572,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 +701,7 @@ enum SleepService { try? context.save() } + } @MainActor diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index 8d6cbdf..d75a91f 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 @@ -173,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) @@ -238,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" @@ -305,6 +382,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 +415,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/Services/SleepSegmentation.swift b/PulseLoop/Services/SleepSegmentation.swift new file mode 100644 index 0000000..ca5ba61 --- /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) + } +} diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index 8285e3f..369c301 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,14 +87,97 @@ 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 } + } + // 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) { + 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 @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) diff --git a/PulseLoopTests/SleepServiceTests.swift b/PulseLoopTests/SleepServiceTests.swift index 779906d..cc18f02 100644 --- a/PulseLoopTests/SleepServiceTests.swift +++ b/PulseLoopTests/SleepServiceTests.swift @@ -217,4 +217,271 @@ 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 + + /// 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) -> 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 + 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 NightNapSeed(day: day, nightStart: nightStart, nightEnd: nightEnd, napStart: napStart, napEnd: 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") + } + + // 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") + } }