Skip to content
43 changes: 22 additions & 21 deletions PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SleepSession>())) ?? []
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<SleepStageBlock>())) ?? []).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<SleepStageBlock>())) ?? [])
.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]
Expand All @@ -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)
}
}
4 changes: 4 additions & 0 deletions PulseLoop/PulseLoopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions PulseLoop/Services/PulseServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -698,6 +701,7 @@ enum SleepService {

try? context.save()
}

}

@MainActor
Expand Down
85 changes: 83 additions & 2 deletions PulseLoop/Services/SleepInsights.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading