diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 7bd50bb..cef028f 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -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 { @@ -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())) ?? [] let session = allSessions.first { calendar.isDate($0.date, inSameDayAs: dateKey) } ?? { diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index ae98cbf..dbf5121 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -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. @@ -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 @@ -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() + 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()) 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() + 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 diff --git a/PulseLoopTests/SleepServiceTests.swift b/PulseLoopTests/SleepServiceTests.swift index ee98535..ec904d0 100644 --- a/PulseLoopTests/SleepServiceTests.swift +++ b/PulseLoopTests/SleepServiceTests.swift @@ -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) @@ -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()).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()) + 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()) + XCTAssertEqual(blocks.count, 1) + XCTAssertEqual(blocks.first?.sessionId, session2.id) + } }