Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ final class EventPersistenceSubscriber {
self.context = context
}

// Explicit deinit: cancels the outstanding event/flush tasks and (crucially) gives this
// @MainActor class a non-isolated deinit. Without one, the compiler synthesizes a main-actor
// -isolated deinit that hops through `swift_task_deinitOnExecutorMainActorBackDeploy` on dealloc;
// that back-deploy shim double-frees on iOS < 26.5 (the CI runner's runtime), aborting the test
// process with SIGABRT when a unit test creates and drops a subscriber. See the CI notes in ci.yml.
deinit {
task?.cancel()
flushTask?.cancel()
}

func start() {
guard task == nil else { return }
task = Task {
Expand Down Expand Up @@ -293,7 +303,15 @@ final class EventPersistenceSubscriber {
/// packet. Mirrors `persistence._on_sleep_timeline`.
private func persistSleepTimeline(start: Date, stages: [SleepStage]) {
let calendar = Calendar.current
let dateKey = calendar.startOfDay(for: start)

// Group packets using a noon-to-noon boundary (standard for sleep tracking).
// Sleep starting at or after 12:00 PM (noon) belongs to the next day's waking morning.
let hour = calendar.component(.hour, from: start)
let wakingDay = hour >= 12
? calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: start)) ?? start
: calendar.startOfDay(for: start)
let dateKey = calendar.startOfDay(for: wakingDay)

let allSessions = (try? context.fetch(FetchDescriptor<SleepSession>())) ?? []
let session = allSessions.first { calendar.isDate($0.date, inSameDayAs: dateKey) }
?? {
Expand Down
59 changes: 59 additions & 0 deletions PulseLoop/Services/PulseServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ enum MetricsService {
@MainActor
enum SleepService {
static func latestSleep(context: ModelContext) -> SleepSummary? {
deduplicateSleepSessions(context: context)
guard let session = SleepRepository.latestSession(context: context) else { return nil }
// Gate on *today* so the Today screen only shows a recent night; a session more than a
// day old (with nothing newer) is hidden rather than shown as if it were last night.
Expand All @@ -517,6 +518,7 @@ enum SleepService {
}

static func sleepRange(_ range: SleepRangeKey, context: ModelContext, now: Date = Date()) -> SleepRangeSummary {
deduplicateSleepSessions(context: context)
let expected = expectedNights(for: range)
// Day view is "last night" — anchored on the current reference night, not
// the latest recorded one. If nothing was captured we want to show the
Expand Down Expand Up @@ -582,6 +584,63 @@ enum SleepService {
}
return Calendar.current.startOfDay(for: Date())
}

private static func deduplicateSleepSessions(context: ModelContext) {
let calendar = Calendar.current

// 1. Re-align all session dates using the noon-to-noon boundary
let descriptor = FetchDescriptor<SleepSession>()
guard let allSessions = try? context.fetch(descriptor) else { return }

var modified = false
for session in allSessions {
let hour = calendar.component(.hour, from: session.startAt)
let wakingDay = hour >= 12
? calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: session.startAt)) ?? session.startAt
: calendar.startOfDay(for: session.startAt)
let targetDate = calendar.startOfDay(for: wakingDay)

if calendar.startOfDay(for: session.date) != targetDate {
session.date = targetDate
session.updatedAt = Date()
modified = true
}
}

if modified {
try? context.save()
}

// 2. Find and delete duplicate sessions for the same waking date, keeping the longest one
guard let updatedSessions = try? context.fetch(FetchDescriptor<SleepSession>()) else { return }
let grouped = Dictionary(grouping: updatedSessions) { calendar.startOfDay(for: $0.date) }

for (_, sessionsForDate) in grouped where sessionsForDate.count > 1 {
let sorted = sessionsForDate.sorted { s1, s2 in
if s1.totalMinutes != s2.totalMinutes {
return s1.totalMinutes > s2.totalMinutes
}
return s1.id.uuidString < s2.id.uuidString
}

let toKeep = sorted[0]
let toDelete = sorted.suffix(from: 1)

for session in toDelete {
// Delete associated stage blocks
let blockDescriptor = FetchDescriptor<SleepStageBlock>()
if let blocks = try? context.fetch(blockDescriptor) {
let sessionBlocks = blocks.filter { $0.sessionId == session.id }
for block in sessionBlocks {
context.delete(block)
}
}
context.delete(session)
}
}

try? context.save()
}
}

@MainActor
Expand Down
78 changes: 77 additions & 1 deletion PulseLoopTests/SleepServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ final class SleepServiceTests: XCTestCase {

func testBlocksOnlyForDayRange() throws {
let context = try TestSupport.makeContext()
TestSupport.insertSleep(nightStart: night(0), stages: Array(repeating: .light, count: 60), into: context)
TestSupport.insertSleep(nightStart: night(-1), stages: Array(repeating: .light, count: 60), into: context)
// Pin `now` to noon today so the Day window resolves to today regardless of when the suite runs
// (before 4 AM the reference night is yesterday, which would exclude tonight's session).
XCTAssertFalse(SleepService.sleepRange(.day, context: context, now: noonToday()).sessions.first?.blocks.isEmpty ?? true)
Expand All @@ -92,4 +92,80 @@ final class SleepServiceTests: XCTestCase {
XCTAssertEqual(SleepService.dayReferenceNight(now: at3am), yesterday, "before 4 AM, still last night")
XCTAssertEqual(SleepService.dayReferenceNight(now: at4am), today, "from 4 AM, flip to today")
}

func testCrossMidnightSleepMerging() throws {
let context = try TestSupport.makeContext()

let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let yesterday = cal.date(byAdding: .day, value: -1, to: today)!

// Use the subscriber directly to persist the sleep timeline packets synchronously:
let subscriber = EventPersistenceSubscriber(context: context)

// 1. A packet starting at 11:30 PM yesterday (June 22)
let start1 = cal.date(bySettingHour: 23, minute: 30, second: 0, of: yesterday)!
subscriber.persist(.sleepTimeline(timestamp: start1, stages: Array(repeating: SleepStage.light, count: 15)))

// 2. A packet starting at 12:15 AM today (June 23)
let start2 = cal.date(bySettingHour: 0, minute: 15, second: 0, of: today)!
subscriber.persist(.sleepTimeline(timestamp: start2, stages: Array(repeating: SleepStage.deep, count: 15)))

// There should be only ONE unified session for today
let sessions = SleepRepository.sessions(context: context)
XCTAssertEqual(sessions.count, 1)

guard let session = sessions.first else {
XCTFail("No session was created")
return
}

// Verify that the session is dated today (waking morning)
XCTAssertEqual(cal.startOfDay(for: session.date), today)

// Check that blocks from both packets are present
let blocks = SleepRepository.blocks(sessionId: session.id, context: context)
XCTAssertTrue(blocks.contains { $0.startAt == start1 })
XCTAssertTrue(blocks.contains { $0.startAt == start2 })
}

func testDeduplicateSleepSessions() throws {
let context = try TestSupport.makeContext()
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let yesterday = cal.date(byAdding: .day, value: -1, to: today)!

// Create duplicate sessions like the ones in the bug:
// Session 1: June 22 (duration 45m, start 23:15, end 00:00)
let start = cal.date(bySettingHour: 23, minute: 15, second: 0, of: yesterday)!
let end1 = cal.date(bySettingHour: 0, minute: 0, second: 0, of: today)!
let session1 = SleepSession(date: yesterday, startAt: start, endAt: end1, totalMinutes: 45)
context.insert(session1)
context.insert(SleepStageBlock(sessionId: session1.id, startAt: start, startMinute: 0, durationMinutes: 45, stage: .light))

// Session 2: June 23 (duration 7h 45m, start 23:15, end 07:00)
let end2 = cal.date(bySettingHour: 7, minute: 0, second: 0, of: today)!
let session2 = SleepSession(date: today, startAt: start, endAt: end2, totalMinutes: 465)
context.insert(session2)
context.insert(SleepStageBlock(sessionId: session2.id, startAt: start, startMinute: 0, durationMinutes: 465, stage: .deep))

try context.save()

// Assert they both exist initially
XCTAssertEqual(try context.fetch(FetchDescriptor<SleepSession>()).count, 2)

// Trigger latestSleep which executes deduplication
let latest = SleepService.latestSleep(context: context)

// Only one session should remain, and it should be the 465-minute one (7h 45m)
let sessions = try context.fetch(FetchDescriptor<SleepSession>())
XCTAssertEqual(sessions.count, 1)
XCTAssertEqual(sessions.first?.totalMinutes, 465)
XCTAssertEqual(latest?.session.totalMinutes, 465)

// The orphaned block for the deleted session should be cleaned up
let blocks = try context.fetch(FetchDescriptor<SleepStageBlock>())
XCTAssertEqual(blocks.count, 1)
XCTAssertEqual(blocks.first?.sessionId, session2.id)
}
}
Loading